diff --git a/.github/workflows/csharp.yml b/.github/workflows/csharp.yml index c619a16f..313059d2 100644 --- a/.github/workflows/csharp.yml +++ b/.github/workflows/csharp.yml @@ -150,6 +150,8 @@ jobs: id: version-check run: | set -euo pipefail + # shellcheck source=scripts/ci/registry-probe.sh + . "$GITHUB_WORKSPACE/scripts/ci/registry-probe.sh" PACKAGE_VERSION=$(sed -n 's|.*\(.*\).*|\1|p' "$PROJECT_PATH") if [ -z "$PACKAGE_VERSION" ]; then echo "::error::Could not read from $PROJECT_PATH" @@ -161,7 +163,7 @@ jobs: echo "Package: $PACKAGE_ID@$PACKAGE_VERSION" # Flat-container URLs are lowercase-only. - if curl -fsS "https://api.nuget.org/v3-flatcontainer/$PACKAGE_ID_LOWER/$PACKAGE_VERSION/$PACKAGE_ID_LOWER.nuspec" > /dev/null 2>&1; then + if probe_registry "https://api.nuget.org/v3-flatcontainer/$PACKAGE_ID_LOWER/$PACKAGE_VERSION/$PACKAGE_ID_LOWER.nuspec"; then echo "Version $PACKAGE_VERSION already exists on NuGet.org" echo "should_publish=false" >> "$GITHUB_OUTPUT" else @@ -207,18 +209,15 @@ jobs: PACKAGE_VERSION: ${{ steps.version-check.outputs.version }} run: | set -euo pipefail + # shellcheck source=scripts/ci/registry-probe.sh + . "$GITHUB_WORKSPACE/scripts/ci/registry-probe.sh" # NuGet.org indexes asynchronously; poll rather than assume success. - for attempt in $(seq 1 20); do - if curl -fsS "https://api.nuget.org/v3-flatcontainer/${PACKAGE_ID_LOWER}/index.json" \ - | grep -q "\"${PACKAGE_VERSION}\""; then - echo "Verified ${PACKAGE_ID}@${PACKAGE_VERSION} on NuGet.org (attempt ${attempt})" - exit 0 - fi - echo "Not indexed yet, retrying in 30s (attempt ${attempt}/20)" - sleep 30 - done - echo "::error::${PACKAGE_ID}@${PACKAGE_VERSION} did not appear on NuGet.org within 10 minutes" - exit 1 + # The flat container exposes one document per package, so the body + # has to be matched -- a 200 only proves the package exists at all. + wait_for_registry_match \ + "${PACKAGE_ID}@${PACKAGE_VERSION} on NuGet.org" \ + "https://api.nuget.org/v3-flatcontainer/${PACKAGE_ID_LOWER}/index.json" \ + "\"${PACKAGE_VERSION}\"" 20 30 generatePdfWithCode: runs-on: ubuntu-latest diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index bd959c68..f5306f84 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -212,15 +212,10 @@ jobs: PACKAGE_VERSION: ${{ steps.version-check.outputs.version }} run: | set -euo pipefail + # shellcheck source=scripts/ci/registry-probe.sh + . "$GITHUB_WORKSPACE/scripts/ci/registry-probe.sh" # The proxy only fetches a version once someone asks for it, so this # also warms it for the first consumer. - for attempt in $(seq 1 20); do - if curl -fsS "https://proxy.golang.org/${MODULE}/@v/v${PACKAGE_VERSION}.info" >/dev/null 2>&1; then - echo "Verified ${MODULE}@v${PACKAGE_VERSION} on proxy.golang.org (attempt ${attempt})" - exit 0 - fi - echo "Not on the proxy yet, retrying in 15s (attempt ${attempt}/20)" - sleep 15 - done - echo "::error::${MODULE}@v${PACKAGE_VERSION} did not appear on proxy.golang.org within 5 minutes" - exit 1 + wait_for_registry \ + "${MODULE}@v${PACKAGE_VERSION} on proxy.golang.org" \ + "https://proxy.golang.org/${MODULE}/@v/v${PACKAGE_VERSION}.info" 20 15 diff --git a/.github/workflows/java.yml b/.github/workflows/java.yml index 98a4c95b..e1e7b77a 100644 --- a/.github/workflows/java.yml +++ b/.github/workflows/java.yml @@ -187,6 +187,8 @@ jobs: id: version-check run: | set -euo pipefail + # shellcheck source=scripts/ci/registry-probe.sh + . "$GITHUB_WORKSPACE/scripts/ci/registry-probe.sh" PACKAGE_VERSION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout) PACKAGE_GROUP=$(mvn help:evaluate -Dexpression=project.groupId -q -DforceStdout) PACKAGE_ARTIFACT=$(mvn help:evaluate -Dexpression=project.artifactId -q -DforceStdout) @@ -198,7 +200,7 @@ jobs: echo "Package: $PACKAGE_GROUP:$PACKAGE_ARTIFACT:$PACKAGE_VERSION" URL="https://repo1.maven.org/maven2/$(echo "$PACKAGE_GROUP" | tr '.' '/')/$PACKAGE_ARTIFACT/$PACKAGE_VERSION/" - if curl --head --silent --fail "$URL" > /dev/null 2>&1; then + if probe_registry "$URL"; then echo "Version $PACKAGE_VERSION already exists on Maven Central" echo "should_publish=false" >> "$GITHUB_OUTPUT" else @@ -235,18 +237,13 @@ jobs: PACKAGE_VERSION: ${{ steps.version-check.outputs.version }} run: | set -euo pipefail + # shellcheck source=scripts/ci/registry-probe.sh + . "$GITHUB_WORKSPACE/scripts/ci/registry-probe.sh" GROUP_PATH=$(echo "$PACKAGE_GROUP" | tr '.' '/') - URL="https://repo1.maven.org/maven2/${GROUP_PATH}/${PACKAGE_ARTIFACT}/${PACKAGE_VERSION}/${PACKAGE_ARTIFACT}-${PACKAGE_VERSION}.pom" - for attempt in $(seq 1 30); do - if curl --head --silent --fail "$URL" > /dev/null 2>&1; then - echo "Verified ${PACKAGE_GROUP}:${PACKAGE_ARTIFACT}:${PACKAGE_VERSION} on Maven Central (attempt ${attempt})" - exit 0 - fi - echo "Not synced yet, retrying in 30s (attempt ${attempt}/30)" - sleep 30 - done - echo "::error::${PACKAGE_GROUP}:${PACKAGE_ARTIFACT}:${PACKAGE_VERSION} did not appear on Maven Central within 15 minutes" - exit 1 + wait_for_registry \ + "${PACKAGE_GROUP}:${PACKAGE_ARTIFACT}:${PACKAGE_VERSION} on Maven Central" \ + "https://repo1.maven.org/maven2/${GROUP_PATH}/${PACKAGE_ARTIFACT}/${PACKAGE_VERSION}/${PACKAGE_ARTIFACT}-${PACKAGE_VERSION}.pom" \ + 30 30 publishRelease: runs-on: ubuntu-latest diff --git a/.github/workflows/js.yml b/.github/workflows/js.yml index dfc15004..8b95d1e2 100644 --- a/.github/workflows/js.yml +++ b/.github/workflows/js.yml @@ -237,16 +237,11 @@ jobs: PACKAGE_VERSION: ${{ steps.version-check.outputs.version }} run: | set -euo pipefail - for attempt in $(seq 1 10); do - if curl -fsS "https://registry.npmjs.org/${PACKAGE_NAME}/${PACKAGE_VERSION}" >/dev/null 2>&1; then - echo "Verified ${PACKAGE_NAME}@${PACKAGE_VERSION} on npm (attempt ${attempt})" - exit 0 - fi - echo "Not visible yet, retrying in 15s (attempt ${attempt}/10)" - sleep 15 - done - echo "::error::${PACKAGE_NAME}@${PACKAGE_VERSION} did not appear on the npm registry within 2.5 minutes" - exit 1 + # shellcheck source=scripts/ci/registry-probe.sh + . "$GITHUB_WORKSPACE/scripts/ci/registry-probe.sh" + wait_for_registry \ + "${PACKAGE_NAME}@${PACKAGE_VERSION} on npm" \ + "https://registry.npmjs.org/${PACKAGE_NAME}/${PACKAGE_VERSION}" 10 15 publishRelease: runs-on: ubuntu-latest diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index a6502545..e870b401 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -150,6 +150,8 @@ jobs: id: version-check run: | set -euo pipefail + # shellcheck source=scripts/ci/registry-probe.sh + . "$GITHUB_WORKSPACE/scripts/ci/registry-probe.sh" PACKAGE_NAME=$(php -r 'echo json_decode(file_get_contents("composer.json"))->name;') PACKAGE_VERSION=$(php -r 'echo json_decode(file_get_contents("composer.json"))->version;') echo "name=$PACKAGE_NAME" >> "$GITHUB_OUTPUT" @@ -159,7 +161,7 @@ jobs: # Distinguish "package is not on Packagist at all" from "this version # is not there yet". The old check collapsed both into should_publish # and then the update call quietly did nothing. - if ! METADATA=$(curl -fsS "https://repo.packagist.org/p2/${PACKAGE_NAME}.json" 2>/dev/null); then + if ! METADATA=$(fetch_registry "https://repo.packagist.org/p2/${PACKAGE_NAME}.json"); then echo "package_known=false" >> "$GITHUB_OUTPUT" echo "should_publish=true" >> "$GITHUB_OUTPUT" exit 0 @@ -217,18 +219,14 @@ jobs: PACKAGE_VERSION: ${{ steps.version-check.outputs.version }} run: | set -euo pipefail - # Packagist crawls asynchronously after the update call returns 202. - for attempt in $(seq 1 20); do - if curl -fsS "https://repo.packagist.org/p2/${PACKAGE_NAME}.json" \ - | grep -q "\"version\":\"${PACKAGE_VERSION}\""; then - echo "Verified ${PACKAGE_NAME}@${PACKAGE_VERSION} on Packagist (attempt ${attempt})" - exit 0 - fi - echo "Not crawled yet, retrying in 30s (attempt ${attempt}/20)" - sleep 30 - done - echo "::error::${PACKAGE_NAME}@${PACKAGE_VERSION} did not appear on Packagist within 10 minutes" - exit 1 + # shellcheck source=scripts/ci/registry-probe.sh + . "$GITHUB_WORKSPACE/scripts/ci/registry-probe.sh" + # Packagist crawls asynchronously after the update call returns 202, + # and serves every version from one document, so match the body. + wait_for_registry_match \ + "${PACKAGE_NAME}@${PACKAGE_VERSION} on Packagist" \ + "https://repo.packagist.org/p2/${PACKAGE_NAME}.json" \ + "\"version\":\"${PACKAGE_VERSION}\"" 20 30 publishRelease: runs-on: ubuntu-latest diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 5d04d2ca..c16db495 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -157,6 +157,8 @@ jobs: id: version-check run: | set -euo pipefail + # shellcheck source=scripts/ci/registry-probe.sh + . "$GITHUB_WORKSPACE/scripts/ci/registry-probe.sh" read -r PACKAGE_NAME PACKAGE_VERSION <<<"$(python -c " import tomllib with open('pyproject.toml','rb') as f: @@ -170,7 +172,7 @@ jobs: # The JSON API is an exact lookup. The previous `pip index versions | # grep` matched substrings, so 0.1.0 looked published once 0.1.0.1 # existed. - if curl -fsS "https://pypi.org/pypi/${PACKAGE_NAME}/${PACKAGE_VERSION}/json" >/dev/null 2>&1; then + if probe_registry "https://pypi.org/pypi/${PACKAGE_NAME}/${PACKAGE_VERSION}/json"; then echo "Version $PACKAGE_VERSION already exists on PyPI" echo "should_publish=false" >> "$GITHUB_OUTPUT" else @@ -179,12 +181,21 @@ jobs: fi # Trusted publishing when configured; an empty `password` makes the # action fall back to OIDC, and PYPI_TOKEN is only the bootstrap. + # + # The action defaults `attestations` to true, but attestations require + # trusted publishing, so passing a password made every release log + # "the attestations input is ignored" as a warning. The two settings are + # now driven by the same opt-in variable and can no longer contradict + # each other. Register the publisher at + # https://pypi.org/manage/project/links-notation/settings/publishing/ + # and set the `PYPI_TRUSTED_PUBLISHING` repository variable to `true`. - name: Publish to PyPI if: steps.version-check.outputs.should_publish == 'true' uses: pypa/gh-action-pypi-publish@release/v1 with: packages-dir: python/dist - password: ${{ secrets.PYPI_TOKEN }} + password: ${{ vars.PYPI_TRUSTED_PUBLISHING == 'true' && '' || secrets.PYPI_TOKEN }} + attestations: ${{ vars.PYPI_TRUSTED_PUBLISHING == 'true' }} - name: Record publish outcome id: publish run: | @@ -201,16 +212,11 @@ jobs: PACKAGE_VERSION: ${{ steps.version-check.outputs.version }} run: | set -euo pipefail - for attempt in $(seq 1 10); do - if curl -fsS "https://pypi.org/pypi/${PACKAGE_NAME}/${PACKAGE_VERSION}/json" >/dev/null 2>&1; then - echo "Verified ${PACKAGE_NAME}@${PACKAGE_VERSION} on PyPI (attempt ${attempt})" - exit 0 - fi - echo "Not visible yet, retrying in 15s (attempt ${attempt}/10)" - sleep 15 - done - echo "::error::${PACKAGE_NAME}@${PACKAGE_VERSION} did not appear on PyPI within 2.5 minutes" - exit 1 + # shellcheck source=scripts/ci/registry-probe.sh + . "$GITHUB_WORKSPACE/scripts/ci/registry-probe.sh" + wait_for_registry \ + "${PACKAGE_NAME}@${PACKAGE_VERSION} on PyPI" \ + "https://pypi.org/pypi/${PACKAGE_NAME}/${PACKAGE_VERSION}/json" 10 15 publishRelease: runs-on: ubuntu-latest diff --git a/.github/workflows/release-audit.yml b/.github/workflows/release-audit.yml index d12141d0..8c481f89 100644 --- a/.github/workflows/release-audit.yml +++ b/.github/workflows/release-audit.yml @@ -52,7 +52,12 @@ jobs: audit: runs-on: ubuntu-latest - timeout-minutes: 10 + # The wait below can take up to 20 minutes on its own. + timeout-minutes: 35 + permissions: + contents: read + # Required so `gh run list` can see this commit's other workflow runs. + actions: read steps: - uses: actions/checkout@v7 with: @@ -61,9 +66,46 @@ jobs: uses: actions/setup-node@v7 with: node-version: '22' + # On a push the audit is triggered by the very commit whose publish + # workflows are still running, so without this wait it compares the + # just-bumped version against a registry that cannot possibly have it + # yet. Run 33168552493 finished 8 seconds after the push and reported all + # seven languages as drifted while rust was still publishing, six minutes + # from done. Every one of those warnings was noise. + - name: Wait for this commit's publish workflows + if: ${{ github.event_name == 'push' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CI_VERBOSE: ${{ inputs.verbose && 'true' || vars.CI_VERBOSE || 'false' }} + run: | + set -euo pipefail + for attempt in $(seq 1 60); do + # This workflow is itself a run for this commit, so it has to be + # excluded or the wait can never finish. + PENDING=$(gh run list --commit "$GITHUB_SHA" --limit 100 \ + --json name,status \ + --jq '[.[] | select(.name != "release-audit") + | select(.status != "completed")] | length') + if [ "$PENDING" -eq 0 ]; then + echo "All workflows for $GITHUB_SHA have completed (attempt ${attempt})" + exit 0 + fi + if [ "${CI_VERBOSE:-false}" = "true" ]; then + gh run list --commit "$GITHUB_SHA" --limit 100 \ + --json name,status,conclusion \ + --jq '.[] | " \(.name): \(.status) \(.conclusion // "")"' + fi + echo "${PENDING} workflow(s) still running (attempt ${attempt}/60); waiting 20s" + sleep 20 + done + echo "::warning::Workflows for $GITHUB_SHA were still running after 20 minutes; the audit below may report releases that are still in flight" # Drift is reported as annotations rather than enforced as a failure: a # version bump legitimately lands before the release that publishes it. - name: Audit declared versions against the registries env: CI_VERBOSE: ${{ inputs.verbose && 'true' || vars.CI_VERBOSE || 'false' }} + # On a pull request no publish job runs at all, so a bumped version + # being ahead of the registry is the expected state rather than + # drift. Reporting it as a warning there would flag every release PR. + AUDIT_EXPECT_PUBLISHED: ${{ github.event_name != 'pull_request' }} run: node scripts/release-audit.mjs diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 6ee916bb..9523ff73 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -150,10 +150,17 @@ jobs: - name: Build run: cargo build --release # Exchanges the workflow's OIDC identity for a short-lived crates.io - # token. Returns an empty token when trusted publishing is not - # configured for the crate, which the publish step reports explicitly. + # token. Trusted publishing has to be registered on crates.io before it + # can be used; until that is done the action fails with + # "No Trusted Publishing config found" and, because it writes an + # `::error::` annotation, paints a red cross on a run that then publishes + # perfectly well through CARGO_TOKEN. Gating on an opt-in repository + # variable keeps that false positive out of the run summary. Set the + # `CRATES_IO_TRUSTED_PUBLISHING` variable to `true` after configuring + # https://crates.io/crates/links-notation/settings to switch over. - name: Authenticate to crates.io id: cratesio-auth + if: ${{ vars.CRATES_IO_TRUSTED_PUBLISHING == 'true' }} continue-on-error: true uses: rust-lang/crates-io-auth-action@v1 - name: Publish to crates.io @@ -165,6 +172,8 @@ jobs: CARGO_TOKEN: ${{ secrets.CARGO_TOKEN }} run: | set -euo pipefail + # shellcheck source=scripts/ci/registry-probe.sh + . "$GITHUB_WORKSPACE/scripts/ci/registry-probe.sh" read_field() { grep "^$2 = " "$1" | head -1 | sed "s/$2 = \"\(.*\)\"/\1/"; } MACRO_VERSION=$(read_field links-notation-macro/Cargo.toml version) @@ -188,6 +197,15 @@ jobs: publish_crate() { local crate="$1" version="$2" log="$3" + # Every other language workflow checks the registry before pushing. + # Without it `cargo publish` prints a red "already exists on + # crates.io index" error on any re-run of an already released + # version, which reads as a failure in a job that is working + # exactly as intended. + if crate_version_published "$crate" "$version"; then + echo "${crate}@${version} is already on crates.io" + return 2 + fi if cargo publish -p "$crate" 2>&1 | tee "$log"; then echo "Published ${crate}@${version}" return 0 @@ -209,14 +227,11 @@ jobs: if [ $MACRO_STATUS -eq 1 ]; then exit 1; fi if [ $MACRO_STATUS -eq 0 ]; then - for attempt in $(seq 1 20); do - if curl -fsS "https://crates.io/api/v1/crates/links-notation-macro/${MACRO_VERSION}" >/dev/null 2>&1; then - echo "links-notation-macro@${MACRO_VERSION} is visible in the index" - break - fi - echo "Waiting for the index to catch up (attempt ${attempt}/20)" - sleep 15 - done + # The sparse index, not the JSON API, is what `cargo publish` + # resolves the dependency against, so that is what has to catch up. + if ! wait_for_crate_version links-notation-macro "$MACRO_VERSION" 20 15; then + echo "::warning::Continuing anyway; cargo will fail explicitly if the dependency is still unresolvable" + fi fi set +e @@ -235,16 +250,14 @@ jobs: PACKAGE_VERSION: ${{ steps.publish.outputs.version }} run: | set -euo pipefail - for attempt in $(seq 1 20); do - if curl -fsS "https://crates.io/api/v1/crates/${PACKAGE_NAME}/${PACKAGE_VERSION}" >/dev/null 2>&1; then - echo "Verified ${PACKAGE_NAME}@${PACKAGE_VERSION} on crates.io (attempt ${attempt})" - exit 0 - fi - echo "Not visible yet, retrying in 15s (attempt ${attempt}/20)" - sleep 15 - done - echo "::error::${PACKAGE_NAME}@${PACKAGE_VERSION} did not appear on crates.io within 5 minutes" - exit 1 + # shellcheck source=scripts/ci/registry-probe.sh + . "$GITHUB_WORKSPACE/scripts/ci/registry-probe.sh" + # Check the index first: it is the artifact consumers actually + # resolve against, and unlike the JSON API it is not rate limited. + wait_for_crate_version "$PACKAGE_NAME" "$PACKAGE_VERSION" 20 15 + wait_for_registry \ + "${PACKAGE_NAME}@${PACKAGE_VERSION} on crates.io" \ + "https://crates.io/api/v1/crates/${PACKAGE_NAME}/${PACKAGE_VERSION}" 20 15 publishRelease: runs-on: ubuntu-latest diff --git a/.github/workflows/workflows.yml b/.github/workflows/workflows.yml index 38516981..81a85745 100644 --- a/.github/workflows/workflows.yml +++ b/.github/workflows/workflows.yml @@ -11,9 +11,11 @@ on: branches: main paths: - '.github/**' + - 'scripts/ci/**' pull_request: paths: - '.github/**' + - 'scripts/ci/**' workflow_dispatch: permissions: @@ -40,6 +42,28 @@ jobs: with: args: -color + ci-scripts: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + # actionlint only reaches shell that is inlined in a `run:` block, so the + # shared helper the publish workflows source needs its own gate. + - name: Run shellcheck + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install --yes shellcheck + shellcheck -x scripts/ci/*.sh + # Guards the fix for issue #298: the registry probes have to send an + # identifying User-Agent, because crates.io answers 403 without one. The + # test runs against a local server, so it needs no network and cannot go + # flaky. + - name: Test the registry probe + run: bash scripts/ci/registry-probe.test.sh + zizmor: runs-on: ubuntu-latest timeout-minutes: 10 diff --git a/.gitignore b/.gitignore index 86e8e3e5..c92f5498 100644 --- a/.gitignore +++ b/.gitignore @@ -359,3 +359,11 @@ csharp/fvextra.sty # lychee link-checker cache (see .github/workflows/links.yml) .lycheecache + +# The development log is a deliverable, not build output. Several rules above +# are meant for build artefacts but match it by accident: `[Ll]og/` matches the +# `dev/log/` directory itself, and `*.log` matches the CI logs collected under +# it. Without these two lines every `dev/log` file has to be force-added, which +# is how it gets forgotten. +!dev/log/ +!dev/log/** diff --git a/.gitkeep b/.gitkeep index d365b986..487e7055 100644 --- a/.gitkeep +++ b/.gitkeep @@ -1,3 +1,4 @@ # .gitkeep file auto-generated at 2026-08-28T06:09:39.810Z for PR creation at branch issue-288-cc91e23553e8 for issue https://github.com/link-foundation/links-notation/issues/288 # Updated: 2026-08-28T07:33:05.436Z -# Updated: 2026-08-28T11:11:17.987Z \ No newline at end of file +# Updated: 2026-08-28T11:11:17.987Z +# Updated: 2026-08-28T12:23:46.151Z \ No newline at end of file diff --git a/csharp/Link.Foundation.Links.Notation/Link.Foundation.Links.Notation.csproj b/csharp/Link.Foundation.Links.Notation/Link.Foundation.Links.Notation.csproj index 4e6dc31a..d75d347f 100644 --- a/csharp/Link.Foundation.Links.Notation/Link.Foundation.Links.Notation.csproj +++ b/csharp/Link.Foundation.Links.Notation/Link.Foundation.Links.Notation.csproj @@ -4,7 +4,7 @@ Link.Foundation's Links Notation parser and formatter for .NET Konstantin Diachenko Link.Foundation.Links.Notation - 0.16.0 + 0.16.1 Konstantin Diachenko net10.0 Link.Foundation.Links.Notation diff --git a/dev/log/issues/298/pulls/299/CI-CD-BEST-PRACTICES.md b/dev/log/issues/298/pulls/299/CI-CD-BEST-PRACTICES.md new file mode 100644 index 00000000..f68503e6 --- /dev/null +++ b/dev/log/issues/298/pulls/299/CI-CD-BEST-PRACTICES.md @@ -0,0 +1,446 @@ + + +# CI/CD Best Practices for AI-Driven Development (languages: en • [zh](https://github.com/link-assistant/hive-mind/blob/main/docs/CI-CD-BEST-PRACTICES.zh.md) • [hi](https://github.com/link-assistant/hive-mind/blob/main/docs/CI-CD-BEST-PRACTICES.hi.md) • [ru](https://github.com/link-assistant/hive-mind/blob/main/docs/CI-CD-BEST-PRACTICES.ru.md)) + +This document describes CI/CD best practices that significantly improve the quality and reliability of AI-driven development workflows. When properly configured, Hive Mind AI solvers are forced to iterate with CI/CD checks until all tests pass, ensuring code quality meets the highest standards. + +## Why CI/CD Matters for AI Development + +Hive Mind's AI issue solver is instructed to pay attention to CI/CD checks in each pull request. This creates a powerful feedback loop: + +1. **AI creates a solution** - The solver generates code based on issue requirements +2. **CI/CD validates the solution** - Automated checks verify code quality +3. **AI iterates until passing** - The solver fixes issues until all checks pass +4. **Quality is guaranteed** - No code merges without passing all gates + +This approach ensures consistent quality regardless of whether the team consists of humans, AIs, or both. + +## Recommended CI/CD Templates + +We provide ready-to-use templates for multiple languages with all best practices pre-configured: + +| Language | Template Repository | +| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| JavaScript/TypeScript | [js-ai-driven-development-pipeline-template](https://github.com/link-foundation/js-ai-driven-development-pipeline-template) | +| Rust | [rust-ai-driven-development-pipeline-template](https://github.com/link-foundation/rust-ai-driven-development-pipeline-template) | +| Python | [python-ai-driven-development-pipeline-template](https://github.com/link-foundation/python-ai-driven-development-pipeline-template) | +| Go | [go-ai-driven-development-pipeline-template](https://github.com/link-foundation/go-ai-driven-development-pipeline-template) | +| C# | [csharp-ai-driven-development-pipeline-template](https://github.com/link-foundation/csharp-ai-driven-development-pipeline-template) | +| Java | [java-ai-driven-development-pipeline-template](https://github.com/link-foundation/java-ai-driven-development-pipeline-template) | +| PHP | [php-ai-driven-development-pipeline-template](https://github.com/link-foundation/php-ai-driven-development-pipeline-template) | + +> **Tip:** You don't have to pick a template by hand. Run `fix --ci-cd` (see [Automatic CI/CD Remediation](#automatic-cicd-remediation)) and Hive Mind detects the repository's languages and selects the matching templates for you. + +## Key CI/CD Principles + +### 1. Run Checks Only on Relevant File Changes + +**Only trigger checks when relevant files change.** This dramatically reduces CI costs and run times. + +Use a `detect-changes` job at the start of your workflow to determine which file categories changed: + +```yaml +jobs: + detect-changes: + runs-on: ubuntu-latest + outputs: + code-changed: ${{ steps.changes.outputs.code }} + docs-changed: ${{ steps.changes.outputs.docs }} + docker-changed: ${{ steps.changes.outputs.docker }} + workflow-changed: ${{ steps.changes.outputs.workflow }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 2 + - name: Detect changes + id: changes + run: node scripts/detect-code-changes.mjs +``` + +Then gate each job on the relevant output: + +```yaml +test-suites: + needs: [detect-changes] + if: needs.detect-changes.outputs.code-changed == 'true' || needs.detect-changes.outputs.workflow-changed == 'true' + # ... + +validate-docs: + needs: [detect-changes] + if: needs.detect-changes.outputs.docs-changed == 'true' + # ... + +docker-pr-check: + needs: [detect-changes] + if: needs.detect-changes.outputs.docker-changed == 'true' || needs.detect-changes.outputs.workflow-changed == 'true' + # ... +``` + +**What to exclude from "code changes" detection:** + +- Markdown files (`*.md`) — documentation-only changes don't need changeset files +- `.changeset/` folder — changeset metadata isn't code +- `data/` and `experiments/` folders — non-production content +- `.gitkeep` files — placeholder files with no functional impact + +**What always triggers checks when changed:** + +- Source code files (`.mjs`, `.ts`, `.py`, `.rs`, `.go`, etc.) +- `package.json` / dependency manifests +- CI/CD workflow files (`.github/workflows/*.yml`) +- `Dockerfile` and related infrastructure files + +### 2. File Size Limits + +**Enforce a maximum of 1000-1500 lines per code file.** + +This constraint benefits both AI and human developers: + +- AI models can read and understand entire files within context windows +- Humans can navigate and comprehend files without cognitive overload +- Forces modular, well-organized code architecture + +Example enforcement in CI (bash): + +```bash +find src/ -name "*.mjs" -type f | while read -r file; do + line_count=$(wc -l < "$file") + if [ "$line_count" -gt 1500 ]; then + echo "ERROR: $file has $line_count lines (limit: 1500)" + echo "::error file=$file::File has $line_count lines (limit: 1500)" + exit 1 + fi +done +``` + +**Synchronize the file-size ESLint rule with the CI check** to catch violations locally before CI: + +```js +// eslint.config.mjs +{ + rules: { + 'max-lines': ['error', { max: 1500 }] + } +} +``` + +### 3. Automated Code Formatting + +Consistent formatting eliminates style debates and reduces diff noise: + +| Language | Tool | +| --------------------- | ----------------------------- | +| JavaScript/TypeScript | ESLint + Prettier | +| Rust | rustfmt | +| Python | Ruff | +| Go | gofmt | +| C# | dotnet format | +| Java | Spotless (Google Java Format) | +| PHP | PHP CS Fixer | + +All templates include pre-commit hooks that run formatters automatically before each commit. + +### 4. Static Analysis & Linting + +Catch bugs and enforce patterns before code reaches review: + +| Language | Tools | +| --------------------- | ----------------------------------- | +| JavaScript/TypeScript | ESLint with strict rules | +| Rust | Clippy (pedantic + nursery) | +| Python | Ruff + mypy | +| Go | go vet + staticcheck | +| C# | .NET analyzers (warnings as errors) | +| Java | SpotBugs (maximum effort) | +| PHP | PHPStan (max level) | + +### 5. Fast-Fail Job Ordering + +**Run fast checks before slow checks** to give the fastest possible feedback: + +``` +Fast checks (~7-30s each): Slow checks (~1-10 min each): +├── test-compilation ├── test-suites (unit tests) +├── lint (format + ESLint) ├── test-execution (integration) +└── check-file-line-limits ├── docker-pr-check + └── helm-pr-check +``` + +Gate slow checks on fast checks: + +```yaml +test-suites: + needs: [test-compilation, lint, check-file-line-limits] + if: | + always() && + !cancelled() && + !contains(needs.*.result, 'failure') && + needs.test-compilation.result == 'success' && + needs.lint.result == 'success' && + needs.check-file-line-limits.result == 'success' +``` + +### 6. Changeset-Based Versioning + +All templates use a changeset system that: + +- **Eliminates merge conflicts** - Each PR creates an independent changeset file +- **Automates version bumps** - Highest bump type wins when merging +- **Generates changelogs** - Release notes are compiled automatically +- **Supports semantic versioning** - patch/minor/major bumps are explicit + +| Language | Tool | +| --------------------- | ---------------------------- | +| JavaScript/TypeScript | @changesets/cli | +| Rust | changelog.d + custom scripts | +| Python | Scriv | +| PHP | changelog.d + custom scripts | +| Go, C#, Java | Custom changeset workflows | + +**Exempt docs-only PRs from changeset requirements:** + +```yaml +changeset-check: + needs: [detect-changes] + if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any-code-changed == 'true' +``` + +Documentation-only changes (updating `.md` files) should not require a version bump. + +### 7. Validate the Actual Merge Result + +**CI must test what will actually be merged, not a stale PR snapshot.** + +When a PR is opened against a base branch that later receives new commits, the GitHub merge preview can become stale. Simulate a fresh merge before running checks: + +```yaml +- name: Simulate fresh merge with base branch (PR only) + if: github.event_name == 'pull_request' + env: + BASE_REF: ${{ github.base_ref }} + run: | + git config user.email "github-actions[bot]@users.noreply.github.com" + git config user.name "github-actions[bot]" + git fetch origin "$BASE_REF" + BEHIND_COUNT=$(git rev-list --count HEAD..origin/$BASE_REF) + if [ "$BEHIND_COUNT" -gt 0 ]; then + git merge origin/$BASE_REF --no-edit || \ + (echo "::error::Merge conflict! PR must be rebased before merging." && exit 1) + fi +``` + +This ensures lint, file-size, and other checks validate the final merged state. + +### 8. Pre-commit Hooks + +Local quality gates prevent broken commits from reaching CI: + +1. Format check and auto-fix +2. Lint and static analysis +3. Type checking (where applicable) +4. File size validation +5. Secrets detection + +This "shift left" approach catches issues immediately rather than waiting for CI. + +### 9. Release Automation + +Automated release workflows ensure: + +- **No manual version management** - Versions update automatically +- **OIDC trusted publishing** - No API tokens needed in CI (npm, PyPI, crates.io) +- **Validated releases only** - All checks must pass before publishing +- **Dual trigger modes** - Both automatic (on merge) and manual (workflow dispatch) + +**Prohibit manual version changes** in PRs — all version bumps should be managed by the CI release workflow: + +```yaml +version-check: + if: github.event_name == 'pull_request' + steps: + - name: Check for version changes in package.json + run: node scripts/check-version.mjs +``` + +### 10. Concurrency Control + +**Separate cancellable read-only checks from non-cancellable write jobs.** Configure concurrency at the job level when a workflow contains both kinds of work: + +```yaml +jobs: + lint: + # Include the job identity (and matrix values, when present) so unrelated + # checks remain parallel while a newer run replaces only the stale check. + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-lint + cancel-in-progress: true + # ... + + deploy: + needs: [lint] + if: ${{ !cancelled() && needs.lint.result == 'success' }} + # Every job that writes to main or an external deployment target uses this + # repository-wide group, even when the jobs live in different workflows. + concurrency: + group: main-writer-${{ github.repository }}-main + cancel-in-progress: false + # ... +``` + +- **Read-only jobs:** Cancel superseded checks on both pull requests and `main` to reduce runner load. Give each job a distinct suffix; include relevant matrix values so different matrix entries can still run in parallel. +- **Dependent writers:** Use `needs` and require successful prerequisites. A cancelled prerequisite must make its write job not start. +- **Active writers:** Give every release, deploy, tag, generated-content push, and other write job the same repository-scoped group with `cancel-in-progress: false`. An already started writer finishes while the next writer waits in the queue, including writers from another workflow file. +- **Workflow scope:** Do not put cancellable concurrency at workflow level when the workflow has write jobs. Cancelling the workflow would also interrupt a writer that has already started. + +By default, a concurrency group keeps at most one running and one pending job; a newer pending writer replaces the older pending writer. If every queued write must run, add `queue: max` to the writer's concurrency block (up to 100 jobs can wait). `queue: max` cannot be combined with `cancel-in-progress: true`, and execution order follows when jobs start waiting rather than workflow dispatch order, so write jobs should remain idempotent. See [GitHub's concurrency documentation](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency) for the current queue limits and semantics. + +Use `!cancelled()` instead of `always()` in job conditions so cancellation propagates correctly through the job graph. A bare `always()` can keep downstream work running after cancellation. + +### 11. Secrets Detection + +Prevent accidental credential leaks in CI: + +- Include a secrets scan step using tools like `secretlint` or `truffleHog` +- Fail CI immediately if secrets are detected +- Never log environment variables or token values + +### 12. Documentation Validation + +**Validate documentation files in CI just like code:** + +- Check file size limits (e.g., max 2500 lines for docs) +- Verify required sections exist in key documents +- Check for broken links using tools like `lychee` + +```yaml +validate-docs: + needs: [detect-changes] + if: needs.detect-changes.outputs.docs-changed == 'true' + steps: + - run: node tests/docs-validation.mjs +``` + +### 13. Container Images: Native Runners per Architecture + +**Build each architecture on its own native runner.** GitHub provides free arm64 Linux runners for public repositories (`ubuntu-24.04-arm`). Emulating arm64 with QEMU on an x86 runner is much slower for compiled languages, and building two architectures inside one job makes them sequential instead of parallel. + +```yaml +build-image: + strategy: + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + steps: + - uses: docker/build-push-action@v7 + with: + platforms: ${{ matrix.platform }} + cache-from: type=gha + cache-to: type=gha,mode=max + outputs: type=image,push-by-digest=true,name-canonical=true,push=true + +merge-manifest: + needs: [build-image] + steps: + - run: docker buildx imagetools create -t $IMAGE:$VERSION $DIGESTS +``` + +- **No `setup-qemu-action`.** Its presence means an architecture is being emulated; use a native runner instead. +- **Publish images for every architecture your users run.** A single-architecture image silently excludes Apple Silicon, Graviton, and arm CI runners. +- **Always cache.** Set `cache-from: type=gha` and `cache-to: type=gha,mode=max` on every build step; otherwise every architecture rebuilds the full dependency tree for every release. +- **Never gate the release on the image push.** Publish the GitHub Release and language-registry package first, then attach images as they finish. Release notes contain no data derived from image bytes, so a slow or failed registry push must not hide an otherwise completed release. +- **Assert what you shipped.** Verify that the published manifest lists every intended platform and that each default-branch tag has a corresponding GitHub Release; a missing release is otherwise easy to overlook. + +Reference implementations: [`link-foundation/box`](https://github.com/link-foundation/box) and [`link-assistant/hive-mind`](https://github.com/link-assistant/hive-mind). + +## Quality Enforcement Strategy + +The templates implement a defense-in-depth approach: + +``` +Developer Machine → CI/CD Pipeline → Release +├── Pre-commit hooks ├── detect-changes ├── All checks pass +├── Local tests ├── version-check ├── Version bump +└── IDE integration ├── changeset-check ├── Changelog update + ├── test-compilation └── Publish package + ├── lint (format+ESLint) + ├── check-file-line-limits + ├── test-suites + ├── test-execution + ├── validate-docs + └── docker-pr-check +``` + +Each layer catches different issues, ensuring no problematic code reaches production. + +## Getting Started + +1. **Choose a template** from the table above matching your language +2. **Use it as a GitHub template** to create your new repository +3. **Configure secrets** if needed for publishing (OIDC preferred) +4. **Start developing** with all best practices pre-configured + +The AI solvers will automatically respect and iterate with all configured checks, producing higher quality output than repositories without CI/CD enforcement. + +## Automatic CI/CD Remediation + +For an existing repository, you don't need to apply these practices by hand. The `fix` command automates the whole flow: + +```bash +fix https://github.com/owner/repo --ci-cd +``` + +This command: + +1. **Detects the repository's languages** using the GitHub Linguist API (`GET /repos/{owner}/{repo}/languages`), ordered by the number of bytes per language. +2. **Selects the matching CI/CD templates** from the table above, sorted so the template for the most-used language comes first. +3. **Inspects the latest default-branch commit** and collects its CI/CD runs (falling back to the most recent runs on the default branch when the latest commit has none). +4. **Creates a remediation issue** that lists the failing runs, the detected languages, the recommended templates, and a link back to this document. The issue is created as a **Bug** (with a `bug` label) and its title and text are taken from the [standard remediation template](https://github.com/link-assistant/web-capture/issues/139). +5. **Hands the issue off to `/solve --development-log --deep-analysis --auto-merge`**, which iterates until the fixes are merged. Every option `fix` does not consume itself (for example `--tool`, `--model`, `--think`) is forwarded to `/solve`. + +### Why the issue is a Bug, and what it leaves out + +`--development-log` replaces the template's retired case-study-folder instruction and collects artifacts under `./dev/log/issues/{issue-id}/pulls/{pull-id}`. `/fix` never emits the retired paragraph, including with `--no-solve` or partial option sets. `--deep-analysis` supplies the timeline, root-cause, debug-output, and upstream-reporting guidance, so `fix` conditionally omits the matching paragraphs instead of delivering them twice. + +That omission is only lossless because `/solve` emits the root-cause wording **only for bug-typed issues** — which is why `fix` creates the issue as a Bug. Issue types are configured per organization and labels per repository, so if the target repository accepts neither, the issue is still created without them. + +The retired paragraph cannot be restored by an option combination; `--development-log` is the only supported collection workflow. The remaining conditional omissions are controlled by `--deep-analysis`. + +### Language → Template Mapping + +The command maps detected languages to templates as follows (JavaScript and TypeScript share a single template): + +| Detected Language(s) | Template | +| --------------------- | ---------------------------------------------------------------- | +| JavaScript/TypeScript | `link-foundation/js-ai-driven-development-pipeline-template` | +| Rust | `link-foundation/rust-ai-driven-development-pipeline-template` | +| Python | `link-foundation/python-ai-driven-development-pipeline-template` | +| Go | `link-foundation/go-ai-driven-development-pipeline-template` | +| C# | `link-foundation/csharp-ai-driven-development-pipeline-template` | +| Java | `link-foundation/java-ai-driven-development-pipeline-template` | +| PHP | `link-foundation/php-ai-driven-development-pipeline-template` | + +Languages without a dedicated template (for example Shell or Dockerfile) are listed in the issue for awareness, and the closest matching template is recommended. + +Use `--dry-run` to preview the issue without creating it, and `--no-solve` to create the issue without starting `/solve`: + +```bash +fix owner/repo --ci-cd --dry-run +fix owner/repo --ci-cd --no-solve +``` + +## References + +- [Code Architecture Principles](https://github.com/link-foundation/code-architecture-principles) +- [Contributing Guidelines](https://github.com/link-assistant/hive-mind/blob/main/docs/CONTRIBUTING.md) +- [Best Practices](https://github.com/link-assistant/hive-mind/blob/main/docs/BEST-PRACTICES.md) diff --git a/dev/log/issues/298/pulls/299/analysis/README.md b/dev/log/issues/298/pulls/299/analysis/README.md new file mode 100644 index 00000000..52e422d9 --- /dev/null +++ b/dev/log/issues/298/pulls/299/analysis/README.md @@ -0,0 +1,352 @@ +# Issue #298 — analysis + +Evidence for this analysis is in the sibling folders: + +| Folder | Contents | +| --- | --- | +| `../ci-logs/` | Raw job logs from the failing run 33168552506 | +| `../api/` | Job and annotation inventories for all 15 runs of the push | +| `../templates/` | The seven pipeline templates' workflows, as downloaded | +| `../CI-CD-BEST-PRACTICES.md` | The referenced best-practices document | + +Two edits were made to the collected evidence, both because a repository-wide +check flagged them and both preserving the content: + +- `../CI-CD-BEST-PRACTICES.md` carries a provenance header and its five + repository-relative links were rewritten to absolute hive-mind URLs, since + they cannot resolve from this directory. This is the same treatment the copy + under `dev/log/issues/290/pulls/291/analysis/` received. +- `../ci-logs/rust-33168552506-publishToCratesIO.log` had the UTF-8 BOM that + `gh run view --log` prepends stripped from byte 1, so it passes `bom-check`. + Nothing else in the file was touched. + +Both were caught by this PR's own CI run 33172747906 / 33172748012, whose logs +are also in `../ci-logs/`. They are recorded here because they are exactly the +kind of finding issue #298 asks for: the checks were right, the newly added +files were wrong. + +## 1. Timeline + +Everything below happened on one push to `main` (commit `2b829f3`, "Merge pull +request #294"), which bumped every implementation from 0.15.0 to 0.16.0. + +| Time (UTC) | Event | +| --- | --- | +| 11:48:28 | The push starts all 15 workflow runs simultaneously | +| 11:48:36 | `release-audit` finishes, **8 seconds in**, and annotates all seven languages as drifted | +| 11:49:42 | `php` finishes; warns that the package is not registered on Packagist | +| 11:50:09 | `cargo publish` reports `Published links-notation v0.16.0 at registry crates-io` | +| 11:50:09 | The next step begins probing `https://crates.io/api/v1/crates/links-notation/0.16.0` | +| 11:50:52 | `python` finishes; warns that `attestations: true` was ignored | +| 11:50:09 → 11:55:10 | Twenty attempts, 15 seconds apart, every one reported as "Not visible yet" | +| 11:55:10 | `::error::links-notation@0.16.0 did not appear on crates.io within 5 minutes`; the run goes red | +| 2026-08-28 (now) | `crates.io` serves 0.16.0, and so do npm, PyPI, NuGet and proxy.golang.org | + +The last row is the decisive one. Running the repository's own audit today: + +``` +js: 0.16.0 (in sync with npm) +python: 0.16.0 (in sync with PyPI) +rust: 0.16.0 (in sync with crates.io) +csharp: 0.16.0 (in sync with NuGet.org) +go: 0.16.0 (in sync with proxy.golang.org) +``` + +The release the workflow declared missing had already shipped when it said so. + +## 2. Requirements + +Enumerated from the issue text, with where each is addressed. + +| # | Requirement | Status | +| --- | --- | --- | +| R1 | Fix the one failing default-branch workflow (`rust`, run 33168552506) | Done — §3.1 | +| R2 | Find and fix **false negatives** across CI/CD | Done — §3.1 | +| R3 | Find and fix **false positives** across CI/CD | Done — §3.2, §3.3 | +| R4 | Find and fix **warnings** | Done — §3.3, §3.4; §5 lists what remains and why | +| R5 | Find and fix **errors** | Done — §3.1, §3.2, §3.5 | +| R6 | Compare **all files** against the seven pipeline templates | Done — §4 | +| R7 | Report the same defect upstream in the templates where it exists | Done — two issues filed, §4.2 | +| R8 | Follow `link-assistant/hive-mind` CI/CD best practices | Done — §6 | +| R9 | Apply each fix **everywhere** it applies, not only where it failed | Done — §3.1, 11 call sites across 7 workflows | +| R10 | Add debug output and a verbose mode, default off | Done — §7 | +| R11 | Everything in the single PR #299 | Done | + +## 3. Root causes + +### 3.1 False negative: crates.io rejects unidentified clients (the failing run) + +The verification step ran, in full: + +```bash +for attempt in $(seq 1 20); do + if curl -fsS "https://crates.io/api/v1/crates/${PACKAGE_NAME}/${PACKAGE_VERSION}" >/dev/null 2>&1; then + echo "Verified ..."; exit 0 + fi + echo "Not visible yet, retrying in 15s (attempt ${attempt}/20)" + sleep 15 +done +echo "::error::${PACKAGE_NAME}@${PACKAGE_VERSION} did not appear on crates.io within 5 minutes" +exit 1 +``` + +**crates.io answers 403 to clients that do not identify themselves**, and curl's +default `User-Agent: curl/8.x` is one of them. `-f` turns 403 into a non-zero +exit, so all twenty attempts failed for a reason unrelated to the release. + +Reproduced in `experiments/issue-298/registry-user-agent-probe.sh`: + +``` +crates.io default-UA=403 explicit-UA=200 <-- DIFFERS +npm default-UA=200 explicit-UA=200 same +pypi default-UA=200 explicit-UA=200 same +nuget default-UA=200 explicit-UA=200 same +goproxy default-UA=200 explicit-UA=200 same +packagist default-UA=404 explicit-UA=404 same +``` + +Only crates.io discriminates, which is why only the rust workflow failed — the +identical pattern in the other six workflows happened to be talking to +registries that tolerate it. That is luck, not correctness. + +A second, compounding defect: `>/dev/null 2>&1` discarded the status code. Had +the log said `HTTP 403` even once, this would have been a five-minute +diagnosis rather than an investigation. The message the step did print — +"did not appear on crates.io" — asserts something the step had no evidence for. + +The repository already knew the rule. `scripts/release-audit.mjs:28` has always +sent `'user-agent': 'links-notation-release-audit'`. The knowledge simply never +reached the workflow. + +**Fix.** `scripts/ci/registry-probe.sh`, sourced by all seven workflows: + +- every request carries an identifying `User-Agent`; +- the last observed status is always in the failure message, so 404 (indexing + lag) is distinguishable from 403 (broken probe); +- `CI_VERBOSE=true` logs every attempt's status. Default off. + +Applied to **all 11 registry call sites across 7 workflows** (R9), not only the +one that failed. + +### 3.2 False positive: an `::error::` annotation for an unconfigured optional feature + +`rust-lang/crates-io-auth-action@v1` fails with + +``` +Failed to retrieve token from Cargo registry. Status: 400. +Error: No Trusted Publishing config found for repository `link-foundation/links-notation`. +``` + +Trusted publishing has to be registered on crates.io first, and it has not +been. The step carried `continue-on-error: true`, so the *job* survived — but +the action writes an `::error::` annotation, and annotations are not suppressed +by `continue-on-error`. Every run got a red error for a fallback path that +worked perfectly. + +**Fix.** Gate the step on an opt-in repository variable +(`vars.CRATES_IO_TRUSTED_PUBLISHING == 'true'`), so it does not run — and +cannot annotate — until trusted publishing is actually configured. + +### 3.3 False positive: the audit races the publishes it audits + +`release-audit` triggers on `push` to `main`. So do the seven publish +workflows. The audit finished **8 seconds** after the push; rust was still +publishing **six minutes later**. Comparing a just-bumped version against a +registry that has not been written to yet can only produce drift, so all seven +warnings were structurally guaranteed: + +``` +::warning::rust: declared 0.16.0, latest on crates.io is 0.15.0. +``` + +Today crates.io serves 0.16.0. The warnings described a race, not drift. + +**Fix.** On `push`, wait for this commit's other workflow runs to complete +before comparing. On `pull_request` no publish job runs at all, so being ahead +of the registry is the expected state there and is reported as a notice. + +### 3.4 Contradictory inputs: PyPI attestations + +`pypa/gh-action-pypi-publish` defaults `attestations` to true, but attestations +require trusted publishing, and the step passes `PYPI_TOKEN`. Every release +therefore logged: + +``` +::warning::The workflow was run with the 'attestations: true' input, but an +explicit password was also set, disabling Trusted Publishing. As a result, the +attestations input is ignored. +``` + +The two inputs contradicted each other and the action was right to say so. + +**Fix.** Both now derive from one variable (`vars.PYPI_TRUSTED_PUBLISHING`), so +they cannot disagree. + +### 3.5 Log noise: publishing a crate that is already published + +The rust workflow re-publishes `links-notation-macro` on every run, so any run +where only the main crate was bumped logs + +``` +error: crate links-notation-macro@0.1.0 already exists on crates.io index +``` + +The calling function handled it and reported "skipped", so the job was correct, +but a red `error:` line in a healthy run is exactly the noise this issue is +about. Every other language workflow has a "Check if version already published" +step; rust did not. + +**Fix.** `crate_version_published` checks the sparse index before publishing. + +A note on why the *index* and not the JSON API: `cargo` resolves dependencies +against `https://index.crates.io//`, so that is the artifact whose +visibility actually matters. The index returns 200 for **any** crate that +exists, so the version has to be matched inside the document — the first draft +of this helper checked only the status and would have reported every version of +an existing crate as published. Three tests now cover it, including that `0.1` +must not match `0.16.0`. + +## 4. Template comparison (R6, R7) + +### 4.1 What the templates do + +None of the seven templates uses bare `curl` to verify a release. Each has a +dedicated script in its own language: + +| Template | Script | Sends a `User-Agent`? | Reports the status on failure? | +| --- | --- | --- | --- | +| rust | `scripts/wait-for-crate.rs` | yes | partially — per-attempt warning only | +| csharp | `scripts/wait-for-nuget.mjs` | n/a (`fetch`) | **yes** — returns `{available, status, url}` | +| js | `scripts/wait-for-npm.mjs` | n/a (`npm view`) | **no** — bare `catch { return false }` | +| php | `scripts/wait-for-packagist.php` | n/a | fails open, continues | +| python, go, java | none | — | — | + +So the specific trigger for #298 — a missing `User-Agent` — **does not exist +upstream**. The rust template sets it correctly. This repository's bash +reimplementation dropped it. No upstream report is warranted for that. + +### 4.2 What does exist upstream + +The *class* of defect does. Two templates collapse "the registry did not +answer" into "the package is not published", and then print a message +asserting the release failed: + +**`rust-ai-driven-development-pipeline-template`** — `wait-for-crate.rs`: + +```rust +Ok(response) => response.status() == 200, +Err(ureq::Error::Status(404, _)) => false, // genuinely not published +Err(e) => { eprintln!("Warning: ..."); false } // 403, 429, 5xx -> "not published" +``` + +A `bool` cannot carry the difference, so the caller cannot report it either. +Filed: + +**`js-ai-driven-development-pipeline-template`** — `wait-for-npm.mjs`: + +```js +} catch { + return false; // E404, EAI_AGAIN, 429, 503, npm-not-on-PATH: all identical +} +``` + +The bare `catch {}` discards the error object entirely. +Filed: + +Both reports include a runnable reproduction, a workaround, and a concrete +code-level fix, and both point at the C# template as the in-family reference +implementation — `wait-for-nuget.mjs` already returns the status alongside the +verdict, which is exactly the shape the other two need. + +## 5. Annotations that remain, and why + +These are accurate statements about optional integrations that are genuinely +not configured. Silencing them would replace a false positive with a false +negative, which is the failure mode this issue exists to remove. + +| Annotation | Severity | Why it stays | +| --- | --- | --- | +| `php: not registered on Packagist` | warning | True. Requires a human to submit the package once at packagist.org. | +| `java: Skipping Maven Central publishing: CENTRAL_USERNAME... not configured` | warning | True. Requires Sonatype credentials as repository secrets. | +| `go: CODECOV_TOKEN is not configured` | notice | True, and already at the right severity. | +| `python: Trusted Publishers allows publishing...` | warning | Emitted by the action while a token is in use. Removable by setting `PYPI_TRUSTED_PUBLISHING` once trusted publishing is registered. | +| `links: Summary report available at ...` | notice | Informational by design. | + +Each is a prompt for a one-time configuration action, listed in §8. + +## 6. Best-practices conformance (R8) + +Checked against `../CI-CD-BEST-PRACTICES.md`. Every language workflow already +had path filters, per-job `concurrency` groups with +`cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}`, `timeout-minutes` +on every job, `persist-credentials: false` on every checkout, and fast-fail +ordering (`lint` → `test` → `publish`). `AutoMerge.yml` has no checkout, so +credential persistence does not apply. + +Two gaps this PR closes: + +- **§4 Static Analysis & Linting** — `actionlint` only reaches shell inlined in + a `run:` block, so the new shared helper had no lint gate. The `workflows` + workflow now runs `shellcheck -x scripts/ci/*.sh` and the helper's test suite, + and its path filter now includes `scripts/ci/**` so changes to it are covered. +- **§9 Release Automation** — verification steps that cannot distinguish + "not published" from "could not check" are not verification. Addressed in §3.1. + +## 7. Debug output and verbose mode (R10) + +The repository already had a `CI_VERBOSE` convention, default off: + +```yaml +CI_VERBOSE: ${{ inputs.verbose && 'true' || vars.CI_VERBOSE || 'false' }} +``` + +The registry polls never used it, which is why the failing run produced twenty +identical lines and no diagnosis. Now: + +- `CI_VERBOSE=true` logs `probe -> HTTP ` for every attempt, and the + full set of versions the sparse index returned; +- the last observed status is in the final `::error::` **unconditionally**, so + the next occurrence is diagnosable from the default log; +- the audit's new wait step lists each pending workflow under `CI_VERBOSE`. + +Default remains off. `scripts/ci/registry-probe.test.sh` asserts both halves: +silence by default, status codes when asked. + +## 8. Follow-up configuration (needs repository/registry access) + +Not code changes; each removes one remaining warning. + +1. Register trusted publishing at , then set the `CRATES_IO_TRUSTED_PUBLISHING` repository variable to `true`. +2. Register trusted publishing at , then set `PYPI_TRUSTED_PUBLISHING` to `true` and drop the `PYPI_TOKEN` secret. +3. Submit the package once at . +4. Add `CENTRAL_USERNAME`, `CENTRAL_TOKEN`, `GPG_PRIVATE_KEY` and `GPG_PASSPHRASE` for Maven Central. +5. Optionally add `CODECOV_TOKEN`. + +## 9. Existing components surveyed + +| Component | Verdict | +| --- | --- | +| [`rust-lang/crates-io-auth-action`](https://github.com/rust-lang/crates-io-auth-action) | Already in use; now gated so it cannot annotate before it is configured. | +| [`pypa/gh-action-pypi-publish`](https://github.com/pypa/gh-action-pypi-publish) | Already in use; its inputs no longer contradict each other. | +| The templates' `wait-for-*` scripts | Reviewed in §4. `wait-for-nuget.mjs` is the reference shape; the others are the subject of the upstream reports. | +| `cargo publish --dry-run`, `cargo-release`, `release-plz` | Rejected. They manage version bumping and publication, which already work here; the defect was in verification, which none of them owns. | +| A generic HTTP-retry action (e.g. `nick-fields/retry`) | Rejected. Retrying was never the problem — every retry did exactly what it was told. The problem was that the predicate was wrong and its result was discarded. | + +The fix is 164 lines of shell with no new dependency, which matters for a step +whose whole job is to be more trustworthy than the thing it verifies. + +## 10. Reproduction and regression test + +- `experiments/issue-298/registry-user-agent-probe.sh` — probes six real + registries with and without an identifying `User-Agent`. This is what + established the root cause. +- `scripts/ci/registry-probe.test.sh` — 21 assertions against a local server + that reproduces the crates.io 403. No network, so it cannot go flaky. It + fails against the pre-fix behaviour: + + ``` + NOT OK - probe_registry got HTTP 403, so it is not sending a real User-Agent + NOT OK - the status code is recorded (expected '200', got '403') + ``` + + Both run in CI via the `ci-scripts` job in `.github/workflows/workflows.yml`. diff --git a/dev/log/issues/298/pulls/299/api/all-annotations.txt b/dev/log/issues/298/pulls/299/api/all-annotations.txt new file mode 100644 index 00000000..ad6b1c33 --- /dev/null +++ b/dev/log/issues/298/pulls/299/api/all-annotations.txt @@ -0,0 +1,17 @@ +33168552493|warning|.github|php: declared 0.16.0, but nothing is published on Packagist. The publish job for this language has never successfully released anything. +33168552493|warning|.github|java: declared 0.16.0, but nothing is published on Maven Central. The publish job for this language has never successfully released anything. +33168552493|warning|.github|go: declared 0.16.0, latest on proxy.golang.org is 0.15.0. +33168552493|warning|.github|csharp: declared 0.16.0, latest on NuGet.org is 0.15.0. +33168552493|warning|.github|rust: declared 0.16.0, latest on crates.io is 0.15.0. +33168552493|warning|.github|python: declared 0.16.0, latest on PyPI is 0.15.0. +33168552493|warning|.github|js: declared 0.16.0, latest on npm is 0.15.0. +33168552483|notice|.github|Summary report available at: https://github.com/link-foundation/links-notation/actions/runs/33168552483#summary-98839751833 +33168552511|notice|.github|CODECOV_TOKEN is not configured, so coverage was not uploaded +33168552472|warning|.github|link-foundation/links-notation is not registered on Packagist (repo.packagist.org returns 404). Submit it once at https://packagist.org/packages/submit; until then no PHP release will be created. +33168552529|warning|.github|Skipping Maven Central publishing: CENTRAL_USERNAME, CENTRAL_TOKEN, GPG_PRIVATE_KEY and GPG_PASSPHRASE secrets are not configured. Generate a token at https://central.sonatype.com/account and add these repository secrets to enable publishing. No GitHub release will be created. +33168552480|warning|.github|A new Trusted Publisher for the currently running publishing workflow can be created by accessing the following link(s) while logged-in as an owner of the package(s): +33168552480|warning|.github|Trusted Publishers allows publishing packages to PyPI from automated environments like GitHub Actions without needing to use username/password combinations or API tokens to authenticate with PyPI. Read more: https://docs.pypi.org/trusted-publishers +33168552480|warning|.github|The workflow was run with the 'attestations: true' input, but an explicit password was also set, disabling Trusted Publishing. As a result, the attestations input is ignored. +33168552506|failure|.github|Process completed with exit code 1. +33168552506|failure|.github|links-notation@0.16.0 did not appear on crates.io within 5 minutes +33168552506|failure|.github|Failed to retrieve token from Cargo registry. Status: 400. Error: No Trusted Publishing config found for repository `link-foundation/links-notation`. diff --git a/dev/log/issues/298/pulls/299/api/all-jobs.txt b/dev/log/issues/298/pulls/299/api/all-jobs.txt new file mode 100644 index 00000000..26bf507a --- /dev/null +++ b/dev/log/issues/298/pulls/299/api/all-jobs.txt @@ -0,0 +1,73 @@ +=== run 33169010058 +findChangedDocsFiles|success +build|success +deploy|success +=== run 33168552493 +audit|success +consistency|success +=== run 33168552483 +linkChecker|success +=== run 33168552490 +check-bom-consistency|success +=== run 33168552532 +actionlint|success +zizmor|success +=== run 33168552511 +findChangedGoFiles|success +lint|success +test|success +publishRelease|success +=== run 33168552472 +findChangedPhpFiles|success +lint|success +test (8.4)|success +test (8.5)|success +publishToPackagist|success +publishRelease|skipped +=== run 33168552485 +findChangedJsFiles|success +format|success +lint|success +test|success +publishToNpm|success +publishRelease|success +=== run 33168552529 +findChangedJavaFiles|success +format|success +build|success +test (21)|success +test (25)|success +publishToMavenCentral|success +publishRelease|skipped +=== run 33168552480 +findChangedPythonFiles|success +lint|success +test|success +publishToPyPI|success +publishRelease|success +=== run 33168552512 +Secret scan|success +CodeQL (javascript-typescript)|success +CodeQL (rust)|success +Audit npm lock (docs/website)|success +Audit npm lock (js)|success +CodeQL (python)|success +CodeQL (actions)|success +CodeQL (go)|success +CodeQL (csharp)|success +CodeQL (java-kotlin)|success +Dependency review|skipped +=== run 33168552506 +findChangedRustFiles|success +lint|success +test|success +publishToCratesIO|failure +publishRelease|skipped +=== run 33168552491 +findChangedCsFiles|success +lint|success +test|success +generatePdfWithCode|success +pushToNuget|success +publishDocumentation|success +publishRelease|success diff --git a/dev/log/issues/298/pulls/299/api/rust-33168552506-jobs.json b/dev/log/issues/298/pulls/299/api/rust-33168552506-jobs.json new file mode 100644 index 00000000..e734d597 --- /dev/null +++ b/dev/log/issues/298/pulls/299/api/rust-33168552506-jobs.json @@ -0,0 +1 @@ +{"conclusion":"failure","createdAt":"2026-08-28T11:48:28Z","displayTitle":"Merge pull request #294 from link-foundation/issue-292-a602b59a2375","headSha":"2b829f37b8369ff5a4b5114923eae4d49f03c78d","jobs":[{"completedAt":"2026-08-28T11:48:35Z","conclusion":"success","databaseId":98839751757,"name":"findChangedRustFiles","startedAt":"2026-08-28T11:48:30Z","status":"completed","steps":[{"completedAt":"2026-08-28T11:48:32Z","conclusion":"success","name":"Set up job","number":1,"startedAt":"2026-08-28T11:48:31Z","status":"completed"},{"completedAt":"2026-08-28T11:48:33Z","conclusion":"success","name":"Run actions/checkout@v7","number":2,"startedAt":"2026-08-28T11:48:32Z","status":"completed"},{"completedAt":"2026-08-28T11:48:33Z","conclusion":"success","name":"Get changed files using defaults","number":3,"startedAt":"2026-08-28T11:48:33Z","status":"completed"},{"completedAt":"2026-08-28T11:48:33Z","conclusion":"success","name":"Set output isRustFilesChanged","number":4,"startedAt":"2026-08-28T11:48:33Z","status":"completed"},{"completedAt":"2026-08-28T11:48:34Z","conclusion":"success","name":"Post Run actions/checkout@v7","number":8,"startedAt":"2026-08-28T11:48:33Z","status":"completed"},{"completedAt":"2026-08-28T11:48:34Z","conclusion":"success","name":"Complete job","number":9,"startedAt":"2026-08-28T11:48:34Z","status":"completed"}],"url":"https://github.com/link-foundation/links-notation/actions/runs/33168552506/job/98839751757"},{"completedAt":"2026-08-28T11:49:25Z","conclusion":"success","databaseId":98839776041,"name":"lint","startedAt":"2026-08-28T11:48:49Z","status":"completed","steps":[{"completedAt":"2026-08-28T11:48:51Z","conclusion":"success","name":"Set up job","number":1,"startedAt":"2026-08-28T11:48:50Z","status":"completed"},{"completedAt":"2026-08-28T11:48:52Z","conclusion":"success","name":"Run actions/checkout@v7","number":2,"startedAt":"2026-08-28T11:48:51Z","status":"completed"},{"completedAt":"2026-08-28T11:48:54Z","conclusion":"success","name":"Setup Rust","number":3,"startedAt":"2026-08-28T11:48:52Z","status":"completed"},{"completedAt":"2026-08-28T11:48:58Z","conclusion":"success","name":"Cache Cargo","number":4,"startedAt":"2026-08-28T11:48:54Z","status":"completed"},{"completedAt":"2026-08-28T11:49:02Z","conclusion":"success","name":"Check formatting","number":5,"startedAt":"2026-08-28T11:48:58Z","status":"completed"},{"completedAt":"2026-08-28T11:49:23Z","conclusion":"success","name":"Run Clippy","number":6,"startedAt":"2026-08-28T11:49:02Z","status":"completed"},{"completedAt":"2026-08-28T11:49:23Z","conclusion":"success","name":"Post Cache Cargo","number":11,"startedAt":"2026-08-28T11:49:23Z","status":"completed"},{"completedAt":"2026-08-28T11:49:23Z","conclusion":"success","name":"Post Run actions/checkout@v7","number":12,"startedAt":"2026-08-28T11:49:23Z","status":"completed"},{"completedAt":"2026-08-28T11:49:23Z","conclusion":"success","name":"Complete job","number":13,"startedAt":"2026-08-28T11:49:23Z","status":"completed"}],"url":"https://github.com/link-foundation/links-notation/actions/runs/33168552506/job/98839776041"},{"completedAt":"2026-08-28T11:49:43Z","conclusion":"success","databaseId":98839951168,"name":"test","startedAt":"2026-08-28T11:49:28Z","status":"completed","steps":[{"completedAt":"2026-08-28T11:49:30Z","conclusion":"success","name":"Set up job","number":1,"startedAt":"2026-08-28T11:49:29Z","status":"completed"},{"completedAt":"2026-08-28T11:49:32Z","conclusion":"success","name":"Run actions/checkout@v7","number":2,"startedAt":"2026-08-28T11:49:30Z","status":"completed"},{"completedAt":"2026-08-28T11:49:32Z","conclusion":"success","name":"Setup Rust","number":3,"startedAt":"2026-08-28T11:49:32Z","status":"completed"},{"completedAt":"2026-08-28T11:49:34Z","conclusion":"success","name":"Cache Cargo","number":4,"startedAt":"2026-08-28T11:49:32Z","status":"completed"},{"completedAt":"2026-08-28T11:49:36Z","conclusion":"success","name":"Build","number":5,"startedAt":"2026-08-28T11:49:34Z","status":"completed"},{"completedAt":"2026-08-28T11:49:41Z","conclusion":"success","name":"Test","number":6,"startedAt":"2026-08-28T11:49:36Z","status":"completed"},{"completedAt":"2026-08-28T11:49:41Z","conclusion":"success","name":"Post Cache Cargo","number":11,"startedAt":"2026-08-28T11:49:41Z","status":"completed"},{"completedAt":"2026-08-28T11:49:41Z","conclusion":"success","name":"Post Run actions/checkout@v7","number":12,"startedAt":"2026-08-28T11:49:41Z","status":"completed"},{"completedAt":"2026-08-28T11:49:41Z","conclusion":"success","name":"Complete job","number":13,"startedAt":"2026-08-28T11:49:41Z","status":"completed"}],"url":"https://github.com/link-foundation/links-notation/actions/runs/33168552506/job/98839951168"},{"completedAt":"2026-08-28T11:55:13Z","conclusion":"failure","databaseId":98840013712,"name":"publishToCratesIO","startedAt":"2026-08-28T11:49:46Z","status":"completed","steps":[{"completedAt":"2026-08-28T11:49:48Z","conclusion":"success","name":"Set up job","number":1,"startedAt":"2026-08-28T11:49:47Z","status":"completed"},{"completedAt":"2026-08-28T11:49:50Z","conclusion":"success","name":"Run actions/checkout@v7","number":2,"startedAt":"2026-08-28T11:49:48Z","status":"completed"},{"completedAt":"2026-08-28T11:49:50Z","conclusion":"success","name":"Setup Rust","number":3,"startedAt":"2026-08-28T11:49:50Z","status":"completed"},{"completedAt":"2026-08-28T11:49:53Z","conclusion":"success","name":"Cache Cargo","number":4,"startedAt":"2026-08-28T11:49:50Z","status":"completed"},{"completedAt":"2026-08-28T11:50:02Z","conclusion":"success","name":"Build","number":5,"startedAt":"2026-08-28T11:49:53Z","status":"completed"},{"completedAt":"2026-08-28T11:50:03Z","conclusion":"success","name":"Authenticate to crates.io","number":6,"startedAt":"2026-08-28T11:50:02Z","status":"completed"},{"completedAt":"2026-08-28T11:50:09Z","conclusion":"success","name":"Publish to crates.io","number":7,"startedAt":"2026-08-28T11:50:03Z","status":"completed"},{"completedAt":"2026-08-28T11:55:10Z","conclusion":"failure","name":"Verify the crate is really on crates.io","number":8,"startedAt":"2026-08-28T11:50:09Z","status":"completed"},{"completedAt":"2026-08-28T11:55:10Z","conclusion":"success","name":"Post Authenticate to crates.io","number":14,"startedAt":"2026-08-28T11:55:10Z","status":"completed"},{"completedAt":"2026-08-28T11:55:10Z","conclusion":"skipped","name":"Post Cache Cargo","number":15,"startedAt":"2026-08-28T11:55:10Z","status":"completed"},{"completedAt":"2026-08-28T11:55:11Z","conclusion":"success","name":"Post Run actions/checkout@v7","number":16,"startedAt":"2026-08-28T11:55:10Z","status":"completed"},{"completedAt":"2026-08-28T11:55:11Z","conclusion":"success","name":"Complete job","number":17,"startedAt":"2026-08-28T11:55:11Z","status":"completed"}],"url":"https://github.com/link-foundation/links-notation/actions/runs/33168552506/job/98840013712"},{"completedAt":"2026-08-28T11:55:13Z","conclusion":"skipped","databaseId":98841191334,"name":"publishRelease","startedAt":"2026-08-28T11:55:14Z","status":"completed","steps":[],"url":"https://github.com/link-foundation/links-notation/actions/runs/33168552506/job/98841191334"}]} diff --git a/dev/log/issues/298/pulls/299/ci-logs/bom-check-33172747906.log b/dev/log/issues/298/pulls/299/ci-logs/bom-check-33172747906.log new file mode 100644 index 00000000..bca99800 --- /dev/null +++ b/dev/log/issues/298/pulls/299/ci-logs/bom-check-33172747906.log @@ -0,0 +1,61 @@ +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7738470Z ##[group]Run echo "Checking for files with Unicode BOM..." +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7739852Z ^[[36;1mecho "Checking for files with Unicode BOM..."^[[0m +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7741091Z ^[[36;1mecho "Repository standard: NO BOM (UTF-8 without BOM)"^[[0m +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7742160Z ^[[36;1mecho ""^[[0m +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7742688Z ^[[36;1m^[[0m +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7743462Z ^[[36;1m# Find all text files that contain BOM (EF BB BF at start)^[[0m +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7744975Z ^[[36;1m# Exclude binary files, .git directory, and common binary extensions^[[0m +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7746237Z ^[[36;1mfiles_with_bom=""^[[0m +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7747184Z ^[[36;1m^[[0m +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7747829Z ^[[36;1mwhile IFS= read -r -d '' file; do^[[0m +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7748841Z ^[[36;1m # Check if file starts with UTF-8 BOM (EF BB BF)^[[0m +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7750073Z ^[[36;1m if head -c 3 "$file" | od -An -tx1 | grep -q "ef bb bf"; then^[[0m +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7751295Z ^[[36;1m files_with_bom="$files_with_bom$file"$'\n'^[[0m +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7752197Z ^[[36;1m fi^[[0m +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7752790Z ^[[36;1mdone < <(find . -type f \^[[0m +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7753545Z ^[[36;1m -not -path './.git/*' \^[[0m +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7754291Z ^[[36;1m -not -name '*.png' \^[[0m +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7754997Z ^[[36;1m -not -name '*.jpg' \^[[0m +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7755715Z ^[[36;1m -not -name '*.jpeg' \^[[0m +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7756435Z ^[[36;1m -not -name '*.gif' \^[[0m +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7757259Z ^[[36;1m -not -name '*.ico' \^[[0m +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7757980Z ^[[36;1m -not -name '*.woff' \^[[0m +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7758703Z ^[[36;1m -not -name '*.woff2' \^[[0m +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7759451Z ^[[36;1m -not -name '*.ttf' \^[[0m +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7760149Z ^[[36;1m -not -name '*.eot' \^[[0m +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7760852Z ^[[36;1m -not -name '*.pdf' \^[[0m +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7761550Z ^[[36;1m -not -name '*.zip' \^[[0m +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7762268Z ^[[36;1m -not -name '*.tar' \^[[0m +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7762971Z ^[[36;1m -not -name '*.gz' \^[[0m +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7763675Z ^[[36;1m -not -name '*.lock' \^[[0m +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7764380Z ^[[36;1m -print0)^[[0m +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7764940Z ^[[36;1m^[[0m +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7765464Z ^[[36;1mif [ -n "$files_with_bom" ]; then^[[0m +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7766710Z ^[[36;1m echo "::error::The following files contain a Unicode BOM but should not"^[[0m +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7768642Z ^[[36;1m echo "ERROR: The following files contain Unicode BOM but should not:"^[[0m +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7769909Z ^[[36;1m echo "$files_with_bom"^[[0m +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7770617Z ^[[36;1m echo ""^[[0m +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7771393Z ^[[36;1m echo "To fix this, remove the BOM from these files."^[[0m +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7772947Z ^[[36;1m # printf, not echo: the point is to show the escape sequences^[[0m +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7774280Z ^[[36;1m # themselves, and echo is allowed to expand them.^[[0m +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7775596Z ^[[36;1m printf '%s\n' "You can use: sed -i '1s/^\xEF\xBB\xBF//' "^[[0m +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7777130Z ^[[36;1m echo "Or configure your editor to save files without BOM."^[[0m +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7778213Z ^[[36;1m exit 1^[[0m +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7778735Z ^[[36;1melse^[[0m +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7779565Z ^[[36;1m echo "SUCCESS: All text files are consistent (no BOM found)."^[[0m +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7780664Z ^[[36;1m exit 0^[[0m +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7781171Z ^[[36;1mfi^[[0m +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7828846Z shell: /usr/bin/bash -e {0} +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.7829602Z ##[endgroup] +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.8009888Z Checking for files with Unicode BOM... +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.8012057Z Repository standard: NO BOM (UTF-8 without BOM) +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:29.8013123Z +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:30.8236450Z ##[error]The following files contain a Unicode BOM but should not +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:30.8244854Z ERROR: The following files contain Unicode BOM but should not: +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:30.8245970Z ./dev/log/issues/298/pulls/299/ci-logs/rust-33168552506-publishToCratesIO.log +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:30.8246700Z +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:30.8246714Z +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:30.8247356Z To fix this, remove the BOM from these files. +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:30.8248520Z You can use: sed -i '1s/^\xEF\xBB\xBF//' +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:30.8249914Z Or configure your editor to save files without BOM. +check-bom-consistency Check for Unicode BOM in text files 2026-08-28T12:50:30.8255414Z ##[error]Process completed with exit code 1. diff --git a/dev/log/issues/298/pulls/299/ci-logs/links-33172748012.log b/dev/log/issues/298/pulls/299/ci-logs/links-33172748012.log new file mode 100644 index 00000000..c28a3d2e --- /dev/null +++ b/dev/log/issues/298/pulls/299/ci-logs/links-33172748012.log @@ -0,0 +1,152 @@ +linkChecker Check links with lychee 2026-08-28T12:50:32.2262349Z ##[group]Run lycheeverse/lychee-action@v2 +linkChecker Check links with lychee 2026-08-28T12:50:32.2262623Z with: +linkChecker Check links with lychee 2026-08-28T12:50:32.2263341Z args: --no-progress --cache --max-cache-age 1d --max-retries 3 --timeout 30 --exclude-path docs/case-studies --exclude-path docs/website/dist --exclude-path js/node_modules --exclude-path docs/website/node_modules './**/*.md' +linkChecker Check links with lychee 2026-08-28T12:50:32.2264375Z fail: true +linkChecker Check links with lychee 2026-08-28T12:50:32.2264557Z jobSummary: true +linkChecker Check links with lychee 2026-08-28T12:50:32.2264725Z debug: false +linkChecker Check links with lychee 2026-08-28T12:50:32.2264886Z failIfEmpty: true +linkChecker Check links with lychee 2026-08-28T12:50:32.2265052Z format: markdown +linkChecker Check links with lychee 2026-08-28T12:50:32.2265222Z lycheeVersion: v0.24.2 +linkChecker Check links with lychee 2026-08-28T12:50:32.2265413Z output: lychee/out.md +linkChecker Check links with lychee 2026-08-28T12:50:32.2265588Z checkbox: true +linkChecker Check links with lychee 2026-08-28T12:50:32.2267834Z token: *** +linkChecker Check links with lychee 2026-08-28T12:50:32.2268018Z workingDirectory: . +linkChecker Check links with lychee 2026-08-28T12:50:32.2268197Z env: +linkChecker Check links with lychee 2026-08-28T12:50:32.2268353Z CI_VERBOSE: false +linkChecker Check links with lychee 2026-08-28T12:50:32.2270309Z GITHUB_TOKEN: *** +linkChecker Check links with lychee 2026-08-28T12:50:32.2270480Z ##[endgroup] +linkChecker Check links with lychee 2026-08-28T12:50:32.2327294Z ##[start-action display=Set up environment;id=__lycheeverse_lychee-action.__run] +linkChecker Check links with lychee 2026-08-28T12:50:32.2369515Z ##[group]Run # Install into $RUNNER_TEMP (not $HOME) so the install path is stable +linkChecker Check links with lychee 2026-08-28T12:50:32.2370356Z ^[[36;1m# Install into $RUNNER_TEMP (not $HOME) so the install path is stable^[[0m +linkChecker Check links with lychee 2026-08-28T12:50:32.2370782Z ^[[36;1m# across composite steps. On some runners $HOME is overridden per step^[[0m +linkChecker Check links with lychee 2026-08-28T12:50:32.2371177Z ^[[36;1m# (e.g. actions/checkout temporarily sets it), which made the PATH^[[0m +linkChecker Check links with lychee 2026-08-28T12:50:32.2371546Z ^[[36;1m# entry from one step and the install in another step diverge.^[[0m +linkChecker Check links with lychee 2026-08-28T12:50:32.2371866Z ^[[36;1mecho "$RUNNER_TEMP/lychee/bin" >> "$GITHUB_PATH"^[[0m +linkChecker Check links with lychee 2026-08-28T12:50:32.2372154Z ^[[36;1mmkdir -p "$RUNNER_TEMP/lychee/bin"^[[0m +linkChecker Check links with lychee 2026-08-28T12:50:32.2412454Z shell: /usr/bin/bash --noprofile --norc -e -o pipefail {0} +linkChecker Check links with lychee 2026-08-28T12:50:32.2412779Z env: +linkChecker Check links with lychee 2026-08-28T12:50:32.2412966Z CI_VERBOSE: false +linkChecker Check links with lychee 2026-08-28T12:50:32.2415378Z GITHUB_TOKEN: *** +linkChecker Check links with lychee 2026-08-28T12:50:32.2415606Z ##[endgroup] +linkChecker Check links with lychee 2026-08-28T12:50:32.2527724Z ##[end-action id=__lycheeverse_lychee-action.__run;outcome=success;conclusion=success;duration_ms=19] +linkChecker Check links with lychee 2026-08-28T12:50:32.2532124Z ##[start-action display=Download and extract lychee in temp directory;id=__lycheeverse_lychee-action.lychee-setup] +linkChecker Check links with lychee 2026-08-28T12:50:32.2552856Z ##[group]Run # Create a temporary directory for downloads and extraction +linkChecker Check links with lychee 2026-08-28T12:50:32.2553302Z ^[[36;1m# Create a temporary directory for downloads and extraction^[[0m +linkChecker Check links with lychee 2026-08-28T12:50:32.2553876Z ^[[36;1mTEMP_DIR="${RUNNER_TEMP}/lychee-download"^[[0m +linkChecker Check links with lychee 2026-08-28T12:50:32.2554160Z ^[[36;1mrm -rf "${TEMP_DIR}" && mkdir -p "${TEMP_DIR}"^[[0m +linkChecker Check links with lychee 2026-08-28T12:50:32.2554408Z ^[[36;1mcd "${TEMP_DIR}"^[[0m +linkChecker Check links with lychee 2026-08-28T12:50:32.2554588Z ^[[36;1m^[[0m +linkChecker Check links with lychee 2026-08-28T12:50:32.2554745Z ^[[36;1mARCH=$(uname -m)^[[0m +linkChecker Check links with lychee 2026-08-28T12:50:32.2554999Z ^[[36;1m# Determine filename and download URL based on version^[[0m +linkChecker Check links with lychee 2026-08-28T12:50:32.2555334Z ^[[36;1mif [[ "${LYCHEE_VERSION}" =~ ^v0\.0|^v0\.1[0-5]\. ]]; then^[[0m +linkChecker Check links with lychee 2026-08-28T12:50:32.2555709Z ^[[36;1m FILENAME="lychee-${LYCHEE_VERSION}-${ARCH}-unknown-linux-gnu.tar.gz"^[[0m +linkChecker Check links with lychee 2026-08-28T12:50:32.2556242Z ^[[36;1m DOWNLOAD_URL="https://github.com/lycheeverse/lychee/releases/download/${LYCHEE_VERSION}/${FILENAME}"^[[0m +linkChecker Check links with lychee 2026-08-28T12:50:32.2556664Z ^[[36;1melse^[[0m +linkChecker Check links with lychee 2026-08-28T12:50:32.2556916Z ^[[36;1m FILENAME="lychee-${ARCH}-unknown-linux-gnu.tar.gz"^[[0m +linkChecker Check links with lychee 2026-08-28T12:50:32.2557287Z ^[[36;1m if [[ "${LYCHEE_VERSION}" == 'nightly' ]]; then^[[0m +linkChecker Check links with lychee 2026-08-28T12:50:32.2557676Z ^[[36;1m DOWNLOAD_URL="https://github.com/lycheeverse/lychee/releases/download/nightly/${FILENAME}"^[[0m +linkChecker Check links with lychee 2026-08-28T12:50:32.2558067Z ^[[36;1m elif [[ "${LYCHEE_VERSION}" == 'latest' ]]; then^[[0m +linkChecker Check links with lychee 2026-08-28T12:50:32.2558452Z ^[[36;1m DOWNLOAD_URL="https://github.com/lycheeverse/lychee/releases/latest/download/${FILENAME}"^[[0m +linkChecker Check links with lychee 2026-08-28T12:50:32.2558799Z ^[[36;1m else^[[0m +linkChecker Check links with lychee 2026-08-28T12:50:32.2559153Z ^[[36;1m DOWNLOAD_URL="https://github.com/lycheeverse/lychee/releases/download/lychee-${LYCHEE_VERSION}/${FILENAME}"^[[0m +linkChecker Check links with lychee 2026-08-28T12:50:32.2559776Z ^[[36;1m fi^[[0m +linkChecker Check links with lychee 2026-08-28T12:50:32.2559932Z ^[[36;1mfi^[[0m +linkChecker Check links with lychee 2026-08-28T12:50:32.2560091Z ^[[36;1m^[[0m +linkChecker Check links with lychee 2026-08-28T12:50:32.2560284Z ^[[36;1mecho "Downloading from: ${DOWNLOAD_URL}"^[[0m +linkChecker Check links with lychee 2026-08-28T12:50:32.2560534Z ^[[36;1mcurl -sfLO "${DOWNLOAD_URL}"^[[0m +linkChecker Check links with lychee 2026-08-28T12:50:32.2560736Z ^[[36;1m^[[0m +linkChecker Check links with lychee 2026-08-28T12:50:32.2560898Z ^[[36;1mecho "Extracting ${FILENAME}"^[[0m +linkChecker Check links with lychee 2026-08-28T12:50:32.2561126Z ^[[36;1mtar -xvzf "${FILENAME}"^[[0m +linkChecker Check links with lychee 2026-08-28T12:50:32.2561336Z ^[[36;1mrm -rv "${FILENAME}"^[[0m +linkChecker Check links with lychee 2026-08-28T12:50:32.2561519Z ^[[36;1m^[[0m +linkChecker Check links with lychee 2026-08-28T12:50:32.2561735Z ^[[36;1m# Detect if lychee binary is in a subfolder within the tar.gz^[[0m +linkChecker Check links with lychee 2026-08-28T12:50:32.2562029Z ^[[36;1mif ! [[ -f "${TEMP_DIR}/lychee" ]]; then^[[0m +linkChecker Check links with lychee 2026-08-28T12:50:32.2562343Z ^[[36;1m TEMP_DIR="$(echo "${TEMP_DIR}"/lychee-*)" # need `echo` to evaluate glob^[[0m +linkChecker Check links with lychee 2026-08-28T12:50:32.2562629Z ^[[36;1mfi^[[0m +linkChecker Check links with lychee 2026-08-28T12:50:32.2562803Z ^[[36;1mif ! [[ -f "${TEMP_DIR}/lychee" ]]; then^[[0m +linkChecker Check links with lychee 2026-08-28T12:50:32.2563141Z ^[[36;1m echo "Cannot find lychee binary in $TEMP_DIR. Did the archive structure change?" >&2^[[0m +linkChecker Check links with lychee 2026-08-28T12:50:32.2563662Z ^[[36;1m exit 1^[[0m +linkChecker Check links with lychee 2026-08-28T12:50:32.2563993Z ^[[36;1mfi^[[0m +linkChecker Check links with lychee 2026-08-28T12:50:32.2564144Z ^[[36;1m^[[0m +linkChecker Check links with lychee 2026-08-28T12:50:32.2564345Z ^[[36;1m# Output temp directory for use in later steps^[[0m +linkChecker Check links with lychee 2026-08-28T12:50:32.2564628Z ^[[36;1mecho "temp_dir=${TEMP_DIR}" >> $GITHUB_OUTPUT^[[0m +linkChecker Check links with lychee 2026-08-28T12:50:32.2602878Z shell: /usr/bin/bash --noprofile --norc -e -o pipefail {0} +linkChecker Check links with lychee 2026-08-28T12:50:32.2603206Z env: +linkChecker Check links with lychee 2026-08-28T12:50:32.2603382Z CI_VERBOSE: false +linkChecker Check links with lychee 2026-08-28T12:50:32.2605843Z GITHUB_TOKEN: *** +linkChecker Check links with lychee 2026-08-28T12:50:32.2606036Z LYCHEE_VERSION: v0.24.2 +linkChecker Check links with lychee 2026-08-28T12:50:32.2606227Z ##[endgroup] +linkChecker Check links with lychee 2026-08-28T12:50:32.2705801Z Downloading from: https://github.com/lycheeverse/lychee/releases/download/lychee-v0.24.2/lychee-x86_64-unknown-linux-gnu.tar.gz +linkChecker Check links with lychee 2026-08-28T12:50:32.7045326Z Extracting lychee-x86_64-unknown-linux-gnu.tar.gz +linkChecker Check links with lychee 2026-08-28T12:50:32.7072647Z lychee-x86_64-unknown-linux-gnu/ +linkChecker Check links with lychee 2026-08-28T12:50:32.7073615Z lychee-x86_64-unknown-linux-gnu/README.md +linkChecker Check links with lychee 2026-08-28T12:50:32.7074181Z lychee-x86_64-unknown-linux-gnu/docs/ +linkChecker Check links with lychee 2026-08-28T12:50:32.7074767Z lychee-x86_64-unknown-linux-gnu/docs/lychee.1 +linkChecker Check links with lychee 2026-08-28T12:50:32.7075377Z lychee-x86_64-unknown-linux-gnu/docs/TROUBLESHOOTING.md +linkChecker Check links with lychee 2026-08-28T12:50:32.7075974Z lychee-x86_64-unknown-linux-gnu/docs/PRE_COMMIT.md +linkChecker Check links with lychee 2026-08-28T12:50:32.7076519Z lychee-x86_64-unknown-linux-gnu/complete/ +linkChecker Check links with lychee 2026-08-28T12:50:32.7077160Z lychee-x86_64-unknown-linux-gnu/complete/_lychee.ps1 +linkChecker Check links with lychee 2026-08-28T12:50:32.7077776Z lychee-x86_64-unknown-linux-gnu/complete/lychee.elv +linkChecker Check links with lychee 2026-08-28T12:50:32.7078400Z lychee-x86_64-unknown-linux-gnu/complete/lychee.fish +linkChecker Check links with lychee 2026-08-28T12:50:32.7078995Z lychee-x86_64-unknown-linux-gnu/complete/lychee.bash +linkChecker Check links with lychee 2026-08-28T12:50:32.7079579Z lychee-x86_64-unknown-linux-gnu/complete/_lychee +linkChecker Check links with lychee 2026-08-28T12:50:32.7080125Z lychee-x86_64-unknown-linux-gnu/lychee +linkChecker Check links with lychee 2026-08-28T12:50:32.8303100Z removed 'lychee-x86_64-unknown-linux-gnu.tar.gz' +linkChecker Check links with lychee 2026-08-28T12:50:32.8332047Z ##[end-action id=__lycheeverse_lychee-action.lychee-setup;outcome=success;conclusion=success;duration_ms=579] +linkChecker Check links with lychee 2026-08-28T12:50:32.8337090Z ##[start-action display=Install lychee;id=__lycheeverse_lychee-action.__run_2] +linkChecker Check links with lychee 2026-08-28T12:50:32.8370856Z ##[group]Run # Install lychee from the temporary directory +linkChecker Check links with lychee 2026-08-28T12:50:32.8371215Z ^[[36;1m# Install lychee from the temporary directory^[[0m +linkChecker Check links with lychee 2026-08-28T12:50:32.8371711Z ^[[36;1minstall -t "$RUNNER_TEMP/lychee/bin" -D "/home/runner/work/_temp/lychee-download/lychee-x86_64-unknown-linux-gnu/lychee"^[[0m +linkChecker Check links with lychee 2026-08-28T12:50:32.8406941Z shell: /usr/bin/bash --noprofile --norc -e -o pipefail {0} +linkChecker Check links with lychee 2026-08-28T12:50:32.8407246Z env: +linkChecker Check links with lychee 2026-08-28T12:50:32.8407424Z CI_VERBOSE: false +linkChecker Check links with lychee 2026-08-28T12:50:32.8409617Z GITHUB_TOKEN: *** +linkChecker Check links with lychee 2026-08-28T12:50:32.8409807Z ##[endgroup] +linkChecker Check links with lychee 2026-08-28T12:50:32.8551274Z ##[end-action id=__lycheeverse_lychee-action.__run_2;outcome=success;conclusion=success;duration_ms=21] +linkChecker Check links with lychee 2026-08-28T12:50:32.8559745Z ##[start-action display=Run Lychee;id=__lycheeverse_lychee-action.run-lychee] +linkChecker Check links with lychee 2026-08-28T12:50:32.8577398Z ##[group]Run /home/runner/work/_actions/lycheeverse/lychee-action/v2/entrypoint.sh +linkChecker Check links with lychee 2026-08-28T12:50:32.8577876Z ^[[36;1m/home/runner/work/_actions/lycheeverse/lychee-action/v2/entrypoint.sh^[[0m +linkChecker Check links with lychee 2026-08-28T12:50:32.8611955Z shell: /usr/bin/bash --noprofile --norc -e -o pipefail {0} +linkChecker Check links with lychee 2026-08-28T12:50:32.8612255Z env: +linkChecker Check links with lychee 2026-08-28T12:50:32.8612427Z CI_VERBOSE: false +linkChecker Check links with lychee 2026-08-28T12:50:32.8614790Z GITHUB_TOKEN: *** +linkChecker Check links with lychee 2026-08-28T12:50:32.8616804Z INPUT_TOKEN: *** +linkChecker Check links with lychee 2026-08-28T12:50:32.8617479Z INPUT_ARGS: --no-progress --cache --max-cache-age 1d --max-retries 3 --timeout 30 --exclude-path docs/case-studies --exclude-path docs/website/dist --exclude-path js/node_modules --exclude-path docs/website/node_modules './**/*.md' +linkChecker Check links with lychee 2026-08-28T12:50:32.8618181Z INPUT_DEBUG: false +linkChecker Check links with lychee 2026-08-28T12:50:32.8618357Z INPUT_FAIL: true +linkChecker Check links with lychee 2026-08-28T12:50:32.8618530Z INPUT_FAILIFEMPTY: true +linkChecker Check links with lychee 2026-08-28T12:50:32.8618723Z INPUT_FORMAT: markdown +linkChecker Check links with lychee 2026-08-28T12:50:32.8618906Z INPUT_JOBSUMMARY: true +linkChecker Check links with lychee 2026-08-28T12:50:32.8619101Z INPUT_CHECKBOX: true +linkChecker Check links with lychee 2026-08-28T12:50:32.8619283Z INPUT_OUTPUT: lychee/out.md +linkChecker Check links with lychee 2026-08-28T12:50:32.8619922Z SUMMARY_URL: https://github.com/link-foundation/links-notation/actions/runs/33172748012#summary-98853657524 +linkChecker Check links with lychee 2026-08-28T12:50:32.8620322Z ##[endgroup] +linkChecker Check links with lychee 2026-08-28T12:50:38.6439139Z Hint: Followed 11 redirects. You might want to consider replacing redirecting URLs with the resolved URLs. Use verbose mode (`-v`/`-vv`) to see redirection details. +linkChecker Check links with lychee 2026-08-28T12:50:38.6530453Z # Summary +linkChecker Check links with lychee 2026-08-28T12:50:38.6530687Z +linkChecker Check links with lychee 2026-08-28T12:50:38.6530808Z | Status | Count | +linkChecker Check links with lychee 2026-08-28T12:50:38.6531139Z |----------------|-------| +linkChecker Check links with lychee 2026-08-28T12:50:38.6531725Z | 🔍 Total | 1132 | +linkChecker Check links with lychee 2026-08-28T12:50:38.6532046Z | 🔗 Unique | 992 | +linkChecker Check links with lychee 2026-08-28T12:50:38.6532400Z | ✅ Successful | 1118 | +linkChecker Check links with lychee 2026-08-28T12:50:38.6532818Z | ⏳ Timeouts | 0 | +linkChecker Check links with lychee 2026-08-28T12:50:38.6533278Z | 🔀 Redirected | 11 | +linkChecker Check links with lychee 2026-08-28T12:50:38.6533821Z | 👻 Excluded | 9 | +linkChecker Check links with lychee 2026-08-28T12:50:38.6534230Z | ❓ Unknown | 0 | +linkChecker Check links with lychee 2026-08-28T12:50:38.6534545Z | 🚫 Errors | 5 | +linkChecker Check links with lychee 2026-08-28T12:50:38.6534902Z | ⛔ Unsupported | 0 | +linkChecker Check links with lychee 2026-08-28T12:50:38.6535066Z +linkChecker Check links with lychee 2026-08-28T12:50:38.6535165Z ## Errors per input +linkChecker Check links with lychee 2026-08-28T12:50:38.6535297Z +linkChecker Check links with lychee 2026-08-28T12:50:38.6535483Z ### Errors in dev/log/issues/298/pulls/299/CI-CD-BEST-PRACTICES.md +linkChecker Check links with lychee 2026-08-28T12:50:38.6535757Z +linkChecker Check links with lychee 2026-08-28T12:50:38.6536377Z * [ERROR] (at 437:3) | File not found. Check if file exists and path is correct +linkChecker Check links with lychee 2026-08-28T12:50:38.6537739Z * [ERROR] (at 1:102) | File not found. Check if file exists and path is correct +linkChecker Check links with lychee 2026-08-28T12:50:38.6539091Z * [ERROR] (at 1:137) | File not found. Check if file exists and path is correct +linkChecker Check links with lychee 2026-08-28T12:50:38.6540502Z * [ERROR] (at 1:67) | File not found. Check if file exists and path is correct +linkChecker Check links with lychee 2026-08-28T12:50:38.6541796Z * [ERROR] (at 436:3) | File not found. Check if file exists and path is correct +linkChecker Check links with lychee 2026-08-28T12:50:38.6542400Z +linkChecker Check links with lychee 2026-08-28T12:50:38.6542409Z +linkChecker Check links with lychee 2026-08-28T12:50:38.6561410Z ##[notice]Summary report available at: https://github.com/link-foundation/links-notation/actions/runs/33172748012#summary-98853657524 +linkChecker Check links with lychee 2026-08-28T12:50:38.6629088Z ##[error]Process completed with exit code 2. +linkChecker Check links with lychee 2026-08-28T12:50:38.6647984Z ##[end-action id=__lycheeverse_lychee-action.run-lychee;outcome=failure;conclusion=failure;duration_ms=5808] diff --git a/dev/log/issues/298/pulls/299/ci-logs/rust-33168552506-failed.log b/dev/log/issues/298/pulls/299/ci-logs/rust-33168552506-failed.log new file mode 100644 index 00000000..4f41d7a3 --- /dev/null +++ b/dev/log/issues/298/pulls/299/ci-logs/rust-33168552506-failed.log @@ -0,0 +1,45 @@ +publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:09.7026208Z ##[group]Run set -euo pipefail +publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:09.7026571Z ^[[36;1mset -euo pipefail^[[0m +publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:09.7026902Z ^[[36;1mfor attempt in $(seq 1 20); do^[[0m +publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:09.7027435Z ^[[36;1m if curl -fsS "https://crates.io/api/v1/crates/${PACKAGE_NAME}/${PACKAGE_VERSION}" >/dev/null 2>&1; then^[[0m +publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:09.7028142Z ^[[36;1m echo "Verified ${PACKAGE_NAME}@${PACKAGE_VERSION} on crates.io (attempt ${attempt})"^[[0m +publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:09.7028591Z ^[[36;1m exit 0^[[0m +publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:09.7028821Z ^[[36;1m fi^[[0m +publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:09.7029464Z ^[[36;1m echo "Not visible yet, retrying in 15s (attempt ${attempt}/20)"^[[0m +publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:09.7029844Z ^[[36;1m sleep 15^[[0m +publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:09.7030063Z ^[[36;1mdone^[[0m +publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:09.7030465Z ^[[36;1mecho "::error::${PACKAGE_NAME}@${PACKAGE_VERSION} did not appear on crates.io within 5 minutes"^[[0m +publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:09.7030922Z ^[[36;1mexit 1^[[0m +publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:09.7067047Z shell: /usr/bin/bash -e {0} +publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:09.7067311Z env: +publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:09.7070645Z GITHUB_TOKEN: *** +publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:09.7070902Z CI_VERBOSE: false +publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:09.7071129Z CARGO_HOME: /home/runner/.cargo +publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:09.7071395Z CARGO_INCREMENTAL: 0 +publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:09.7071620Z CARGO_TERM_COLOR: always +publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:09.7071856Z CACHE_ON_FAILURE: false +publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:09.7072105Z PACKAGE_NAME: links-notation +publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:09.7072357Z PACKAGE_VERSION: 0.16.0 +publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:09.7072586Z ##[endgroup] +publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:09.7617783Z Not visible yet, retrying in 15s (attempt 1/20) +publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:24.8094806Z Not visible yet, retrying in 15s (attempt 2/20) +publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:39.8551976Z Not visible yet, retrying in 15s (attempt 3/20) +publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:50:54.9000478Z Not visible yet, retrying in 15s (attempt 4/20) +publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:51:09.9637544Z Not visible yet, retrying in 15s (attempt 5/20) +publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:51:25.0148386Z Not visible yet, retrying in 15s (attempt 6/20) +publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:51:40.0593201Z Not visible yet, retrying in 15s (attempt 7/20) +publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:51:55.1045223Z Not visible yet, retrying in 15s (attempt 8/20) +publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:52:10.1636203Z Not visible yet, retrying in 15s (attempt 9/20) +publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:52:25.2064098Z Not visible yet, retrying in 15s (attempt 10/20) +publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:52:40.4184624Z Not visible yet, retrying in 15s (attempt 11/20) +publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:52:55.4649465Z Not visible yet, retrying in 15s (attempt 12/20) +publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:53:10.5174684Z Not visible yet, retrying in 15s (attempt 13/20) +publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:53:25.5640997Z Not visible yet, retrying in 15s (attempt 14/20) +publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:53:40.6338362Z Not visible yet, retrying in 15s (attempt 15/20) +publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:53:55.6793835Z Not visible yet, retrying in 15s (attempt 16/20) +publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:54:10.7474875Z Not visible yet, retrying in 15s (attempt 17/20) +publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:54:25.7940404Z Not visible yet, retrying in 15s (attempt 18/20) +publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:54:40.8498536Z Not visible yet, retrying in 15s (attempt 19/20) +publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:54:55.8946448Z Not visible yet, retrying in 15s (attempt 20/20) +publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:55:10.8969926Z ##[error]links-notation@0.16.0 did not appear on crates.io within 5 minutes +publishToCratesIO Verify the crate is really on crates.io 2026-08-28T11:55:10.8973343Z ##[error]Process completed with exit code 1. diff --git a/dev/log/issues/298/pulls/299/ci-logs/rust-33168552506-publishToCratesIO.log b/dev/log/issues/298/pulls/299/ci-logs/rust-33168552506-publishToCratesIO.log new file mode 100644 index 00000000..6b94d7d3 --- /dev/null +++ b/dev/log/issues/298/pulls/299/ci-logs/rust-33168552506-publishToCratesIO.log @@ -0,0 +1,612 @@ +2026-08-28T11:49:47.3653029Z Current runner version: '2.336.0' +2026-08-28T11:49:47.3679778Z ##[group]Runner Image Provisioner +2026-08-28T11:49:47.3680885Z Hosted Compute Agent +2026-08-28T11:49:47.3681559Z Version: 20260819.586 +2026-08-28T11:49:47.3682314Z Commit: 3cc4a88dfa507ef76119ad1bb3eccc6378bb2b76 +2026-08-28T11:49:47.3683165Z Build Date: 2026-08-18T23:20:18Z +2026-08-28T11:49:47.3683924Z Worker ID: {44f5a7b2-9297-4887-a4ff-dd2b838c5f0b} +2026-08-28T11:49:47.3684789Z Azure Region: westus +2026-08-28T11:49:47.3685426Z ##[endgroup] +2026-08-28T11:49:47.3687075Z ##[group]Operating System +2026-08-28T11:49:47.3687798Z Ubuntu +2026-08-28T11:49:47.3688331Z 24.04.4 +2026-08-28T11:49:47.3688904Z LTS +2026-08-28T11:49:47.3689771Z ##[endgroup] +2026-08-28T11:49:47.3690478Z ##[group]Runner Image +2026-08-28T11:49:47.3691198Z Image: ubuntu-24.04 +2026-08-28T11:49:47.3691845Z Version: 20260823.283.1 +2026-08-28T11:49:47.3693237Z Included Software: https://github.com/actions/runner-images/blob/ubuntu24/20260823.283/images/ubuntu/Ubuntu2404-Readme.md +2026-08-28T11:49:47.3694903Z Image Release: https://github.com/actions/runner-images/releases/tag/ubuntu24%2F20260823.283 +2026-08-28T11:49:47.3695999Z ##[endgroup] +2026-08-28T11:49:47.3697225Z ##[group]GITHUB_TOKEN Permissions +2026-08-28T11:49:47.3700017Z Contents: read +2026-08-28T11:49:47.3700749Z Metadata: read +2026-08-28T11:49:47.3701540Z ##[endgroup] +2026-08-28T11:49:47.3703753Z Secret source: Actions +2026-08-28T11:49:47.3705096Z Prepare workflow directory +2026-08-28T11:49:47.4283820Z Prepare all required actions +2026-08-28T11:49:47.4331904Z Getting action download info +2026-08-28T11:49:47.7997139Z Download action repository 'actions/checkout@v7' (SHA:3d3c42e5aac5ba805825da76410c181273ba90b1) +2026-08-28T11:49:47.9180400Z Download action repository 'dtolnay/rust-toolchain@stable' (SHA:4360b52568e2003a75bf9bc1d59f33a8e3fc893c) +2026-08-28T11:49:48.1376511Z Download action repository 'Swatinem/rust-cache@v2' (SHA:6323deb102c322ba6fcbdcafc7e3dddab59af2b6) +2026-08-28T11:49:48.5438980Z Download action repository 'rust-lang/crates-io-auth-action@v1' (SHA:c6f97d42243bad5fab37ca0427f495c86d5b1a18) +2026-08-28T11:49:48.9507110Z Complete job name: publishToCratesIO +2026-08-28T11:49:49.0385421Z ##[group]Run actions/checkout@v7 +2026-08-28T11:49:49.0387032Z with: +2026-08-28T11:49:49.0388078Z persist-credentials: false +2026-08-28T11:49:49.0389492Z submodules: true +2026-08-28T11:49:49.0390644Z repository: link-foundation/links-notation +2026-08-28T11:49:49.0400519Z token: *** +2026-08-28T11:49:49.0401495Z ssh-strict: true +2026-08-28T11:49:49.0402519Z ssh-user: git +2026-08-28T11:49:49.0403476Z clean: true +2026-08-28T11:49:49.0404546Z sparse-checkout-cone-mode: true +2026-08-28T11:49:49.0405748Z fetch-depth: 1 +2026-08-28T11:49:49.0406742Z fetch-tags: false +2026-08-28T11:49:49.0407763Z show-progress: true +2026-08-28T11:49:49.0408808Z lfs: false +2026-08-28T11:49:49.0409968Z set-safe-directory: true +2026-08-28T11:49:49.0411145Z allow-unsafe-pr-checkout: false +2026-08-28T11:49:49.0412635Z env: +2026-08-28T11:49:49.0422298Z GITHUB_TOKEN: *** +2026-08-28T11:49:49.0423320Z CI_VERBOSE: false +2026-08-28T11:49:49.0424322Z ##[endgroup] +2026-08-28T11:49:49.1481809Z Syncing repository: link-foundation/links-notation +2026-08-28T11:49:49.1486600Z ##[group]Getting Git version info +2026-08-28T11:49:49.1489648Z Working directory is '/home/runner/work/links-notation/links-notation' +2026-08-28T11:49:49.1493551Z [command]/usr/bin/git version +2026-08-28T11:49:49.1558169Z git version 2.55.0 +2026-08-28T11:49:49.1631650Z ##[endgroup] +2026-08-28T11:49:49.1639953Z Temporarily overriding HOME='/home/runner/work/_temp/470ce371-7a3e-4201-b88e-730fb838ab60' before making global git config changes +2026-08-28T11:49:49.1643515Z Adding repository directory to the temporary git global config as a safe directory +2026-08-28T11:49:49.1647246Z [command]/usr/bin/git config --global --add safe.directory /home/runner/work/links-notation/links-notation +2026-08-28T11:49:49.1657909Z Deleting the contents of '/home/runner/work/links-notation/links-notation' +2026-08-28T11:49:49.1663197Z ##[group]Determining repository object format +2026-08-28T11:49:49.1666742Z ##[endgroup] +2026-08-28T11:49:49.1669809Z ##[group]Initializing the repository +2026-08-28T11:49:49.1671714Z [command]/usr/bin/git init /home/runner/work/links-notation/links-notation +2026-08-28T11:49:49.1781935Z hint: Using 'master' as the name for the initial branch. This default branch name +2026-08-28T11:49:49.1785673Z hint: will change to "main" in Git 3.0. To configure the initial branch name +2026-08-28T11:49:49.1789748Z hint: to use in all of your new repositories, which will suppress this warning, +2026-08-28T11:49:49.1792715Z hint: call: +2026-08-28T11:49:49.1794225Z hint: +2026-08-28T11:49:49.1796231Z hint: git config --global init.defaultBranch +2026-08-28T11:49:49.1798639Z hint: +2026-08-28T11:49:49.1801177Z hint: Names commonly chosen instead of 'master' are 'main', 'trunk' and +2026-08-28T11:49:49.1804694Z hint: 'development'. The just-created branch can be renamed via this command: +2026-08-28T11:49:49.1807417Z hint: +2026-08-28T11:49:49.1809521Z hint: git branch -m +2026-08-28T11:49:49.1810901Z hint: +2026-08-28T11:49:49.1812283Z hint: Disable this message with "git config set advice.defaultBranchName false" +2026-08-28T11:49:49.1814671Z Initialized empty Git repository in /home/runner/work/links-notation/links-notation/.git/ +2026-08-28T11:49:49.1819962Z [command]/usr/bin/git remote add origin https://github.com/link-foundation/links-notation +2026-08-28T11:49:49.1858594Z ##[endgroup] +2026-08-28T11:49:49.1862145Z ##[group]Disabling automatic garbage collection +2026-08-28T11:49:49.1865019Z [command]/usr/bin/git config --local gc.auto 0 +2026-08-28T11:49:49.1901658Z ##[endgroup] +2026-08-28T11:49:49.1904257Z ##[group]Setting up auth +2026-08-28T11:49:49.1906296Z Removing SSH command configuration +2026-08-28T11:49:49.1909302Z [command]/usr/bin/git config --local --name-only --get-regexp core\.sshCommand +2026-08-28T11:49:49.1947559Z [command]/usr/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'core\.sshCommand' && git config --local --unset-all 'core.sshCommand' || :" +2026-08-28T11:49:49.2321611Z Removing HTTP extra header +2026-08-28T11:49:49.2324872Z [command]/usr/bin/git config --local --name-only --get-regexp http\.https\:\/\/github\.com\/\.extraheader +2026-08-28T11:49:49.2368991Z [command]/usr/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'http\.https\:\/\/github\.com\/\.extraheader' && git config --local --unset-all 'http.https://github.com/.extraheader' || :" +2026-08-28T11:49:49.2593110Z Removing includeIf entries pointing to credentials config files +2026-08-28T11:49:49.2606002Z [command]/usr/bin/git config --local --name-only --get-regexp ^includeIf\.gitdir: +2026-08-28T11:49:49.2644701Z [command]/usr/bin/git submodule foreach --recursive git config --local --show-origin --name-only --get-regexp remote.origin.url +2026-08-28T11:49:49.2876889Z [command]/usr/bin/git config --file /home/runner/work/_temp/git-credentials-55a39926-baa1-4a6f-844d-ff2094c42b53.config http.https://github.com/.extraheader AUTHORIZATION: basic *** +2026-08-28T11:49:49.2919584Z [command]/usr/bin/git config --local includeIf.gitdir:/home/runner/work/links-notation/links-notation/.git.path /home/runner/work/_temp/git-credentials-55a39926-baa1-4a6f-844d-ff2094c42b53.config +2026-08-28T11:49:49.2954907Z [command]/usr/bin/git config --local includeIf.gitdir:/home/runner/work/links-notation/links-notation/.git/worktrees/*.path /home/runner/work/_temp/git-credentials-55a39926-baa1-4a6f-844d-ff2094c42b53.config +2026-08-28T11:49:49.2993390Z [command]/usr/bin/git config --local includeIf.gitdir:/github/workspace/.git.path /github/runner_temp/git-credentials-55a39926-baa1-4a6f-844d-ff2094c42b53.config +2026-08-28T11:49:49.3031741Z [command]/usr/bin/git config --local includeIf.gitdir:/github/workspace/.git/worktrees/*.path /github/runner_temp/git-credentials-55a39926-baa1-4a6f-844d-ff2094c42b53.config +2026-08-28T11:49:49.3063812Z ##[endgroup] +2026-08-28T11:49:49.3065564Z ##[group]Fetching the repository +2026-08-28T11:49:49.3070907Z [command]/usr/bin/git -c protocol.version=2 fetch --no-tags --prune --no-recurse-submodules --depth=1 origin +2b829f37b8369ff5a4b5114923eae4d49f03c78d:refs/remotes/origin/main +2026-08-28T11:49:49.9307999Z From https://github.com/link-foundation/links-notation +2026-08-28T11:49:49.9311010Z * [new ref] 2b829f37b8369ff5a4b5114923eae4d49f03c78d -> origin/main +2026-08-28T11:49:49.9319527Z [command]/usr/bin/git branch --list --remote origin/main +2026-08-28T11:49:49.9351115Z origin/main +2026-08-28T11:49:49.9361546Z [command]/usr/bin/git rev-parse refs/remotes/origin/main +2026-08-28T11:49:49.9390519Z 2b829f37b8369ff5a4b5114923eae4d49f03c78d +2026-08-28T11:49:49.9396153Z ##[endgroup] +2026-08-28T11:49:49.9397649Z ##[group]Determining the checkout info +2026-08-28T11:49:49.9399671Z ##[endgroup] +2026-08-28T11:49:49.9403734Z [command]/usr/bin/git sparse-checkout disable +2026-08-28T11:49:49.9462825Z [command]/usr/bin/git config --local --unset-all extensions.worktreeConfig +2026-08-28T11:49:49.9498405Z ##[group]Checking out the ref +2026-08-28T11:49:49.9501792Z [command]/usr/bin/git checkout --progress --force -B main refs/remotes/origin/main +2026-08-28T11:49:49.9962572Z Switched to a new branch 'main' +2026-08-28T11:49:49.9972794Z branch 'main' set up to track 'origin/main'. +2026-08-28T11:49:49.9980835Z ##[endgroup] +2026-08-28T11:49:49.9983268Z ##[group]Setting up auth for fetching submodules +2026-08-28T11:49:49.9990175Z [command]/usr/bin/git config --file /home/runner/work/_temp/git-credentials-55a39926-baa1-4a6f-844d-ff2094c42b53.config http.https://github.com/.extraheader AUTHORIZATION: basic *** +2026-08-28T11:49:50.0041145Z [command]/usr/bin/git config --global include.path /home/runner/work/_temp/git-credentials-55a39926-baa1-4a6f-844d-ff2094c42b53.config +2026-08-28T11:49:50.0078724Z [command]/usr/bin/git config --global --unset-all url.https://github.com/.insteadOf +2026-08-28T11:49:50.0122629Z [command]/usr/bin/git config --global --add url.https://github.com/.insteadOf git@github.com: +2026-08-28T11:49:50.0160579Z [command]/usr/bin/git config --global --add url.https://github.com/.insteadOf org-176174013@github.com: +2026-08-28T11:49:50.0195394Z ##[endgroup] +2026-08-28T11:49:50.0196866Z ##[group]Fetching submodules +2026-08-28T11:49:50.0199826Z [command]/usr/bin/git submodule sync +2026-08-28T11:49:50.0465793Z [command]/usr/bin/git -c protocol.version=2 submodule update --init --force --depth=1 +2026-08-28T11:49:50.0730815Z [command]/usr/bin/git submodule foreach git config --local gc.auto 0 +2026-08-28T11:49:50.0963570Z ##[endgroup] +2026-08-28T11:49:50.1010399Z [command]/usr/bin/git log -1 --format=%H +2026-08-28T11:49:50.1044561Z 2b829f37b8369ff5a4b5114923eae4d49f03c78d +2026-08-28T11:49:50.1054694Z ##[group]Removing auth +2026-08-28T11:49:50.1055383Z Removing SSH command configuration +2026-08-28T11:49:50.1059826Z [command]/usr/bin/git config --local --name-only --get-regexp core\.sshCommand +2026-08-28T11:49:50.1096704Z [command]/usr/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'core\.sshCommand' && git config --local --unset-all 'core.sshCommand' || :" +2026-08-28T11:49:50.1367330Z Removing HTTP extra header +2026-08-28T11:49:50.1368048Z [command]/usr/bin/git config --local --name-only --get-regexp http\.https\:\/\/github\.com\/\.extraheader +2026-08-28T11:49:50.1418896Z [command]/usr/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'http\.https\:\/\/github\.com\/\.extraheader' && git config --local --unset-all 'http.https://github.com/.extraheader' || :" +2026-08-28T11:49:50.1654955Z Removing includeIf entries pointing to credentials config files +2026-08-28T11:49:50.1658165Z [command]/usr/bin/git config --local --name-only --get-regexp ^includeIf\.gitdir: +2026-08-28T11:49:50.1691441Z includeif.gitdir:/home/runner/work/links-notation/links-notation/.git.path +2026-08-28T11:49:50.1692338Z includeif.gitdir:/home/runner/work/links-notation/links-notation/.git/worktrees/*.path +2026-08-28T11:49:50.1693498Z includeif.gitdir:/github/workspace/.git.path +2026-08-28T11:49:50.1694011Z includeif.gitdir:/github/workspace/.git/worktrees/*.path +2026-08-28T11:49:50.1701092Z [command]/usr/bin/git config --local --get-all includeif.gitdir:/home/runner/work/links-notation/links-notation/.git.path +2026-08-28T11:49:50.1728532Z /home/runner/work/_temp/git-credentials-55a39926-baa1-4a6f-844d-ff2094c42b53.config +2026-08-28T11:49:50.1739840Z [command]/usr/bin/git config --local --unset includeif.gitdir:/home/runner/work/links-notation/links-notation/.git.path \/home\/runner\/work\/_temp\/git\-credentials\-55a39926\-baa1\-4a6f\-844d\-ff2094c42b53\.config +2026-08-28T11:49:50.1781410Z [command]/usr/bin/git config --local --get-all includeif.gitdir:/home/runner/work/links-notation/links-notation/.git/worktrees/*.path +2026-08-28T11:49:50.1812109Z /home/runner/work/_temp/git-credentials-55a39926-baa1-4a6f-844d-ff2094c42b53.config +2026-08-28T11:49:50.1820225Z [command]/usr/bin/git config --local --unset includeif.gitdir:/home/runner/work/links-notation/links-notation/.git/worktrees/*.path \/home\/runner\/work\/_temp\/git\-credentials\-55a39926\-baa1\-4a6f\-844d\-ff2094c42b53\.config +2026-08-28T11:49:50.1858283Z [command]/usr/bin/git config --local --get-all includeif.gitdir:/github/workspace/.git.path +2026-08-28T11:49:50.1885665Z /github/runner_temp/git-credentials-55a39926-baa1-4a6f-844d-ff2094c42b53.config +2026-08-28T11:49:50.1893145Z [command]/usr/bin/git config --local --unset includeif.gitdir:/github/workspace/.git.path \/github\/runner_temp\/git\-credentials\-55a39926\-baa1\-4a6f\-844d\-ff2094c42b53\.config +2026-08-28T11:49:50.1930324Z [command]/usr/bin/git config --local --get-all includeif.gitdir:/github/workspace/.git/worktrees/*.path +2026-08-28T11:49:50.1959306Z /github/runner_temp/git-credentials-55a39926-baa1-4a6f-844d-ff2094c42b53.config +2026-08-28T11:49:50.1966462Z [command]/usr/bin/git config --local --unset includeif.gitdir:/github/workspace/.git/worktrees/*.path \/github\/runner_temp\/git\-credentials\-55a39926\-baa1\-4a6f\-844d\-ff2094c42b53\.config +2026-08-28T11:49:50.2003089Z [command]/usr/bin/git submodule foreach --recursive git config --local --show-origin --name-only --get-regexp remote.origin.url +2026-08-28T11:49:50.2232827Z Removing credentials config '/home/runner/work/_temp/git-credentials-55a39926-baa1-4a6f-844d-ff2094c42b53.config' +2026-08-28T11:49:50.2242109Z ##[endgroup] +2026-08-28T11:49:50.2582566Z ##[group]Run dtolnay/rust-toolchain@stable +2026-08-28T11:49:50.2583012Z with: +2026-08-28T11:49:50.2583291Z toolchain: stable +2026-08-28T11:49:50.2583586Z env: +2026-08-28T11:49:50.2586595Z GITHUB_TOKEN: *** +2026-08-28T11:49:50.2586919Z CI_VERBOSE: false +2026-08-28T11:49:50.2587207Z ##[endgroup] +2026-08-28T11:49:50.2652914Z ##[start-action display=Parse toolchain version;id=__dtolnay_rust-toolchain.parse] +2026-08-28T11:49:50.2713943Z ##[group]Run if [[ -z $toolchain ]]; then +2026-08-28T11:49:50.2714500Z if [[ -z $toolchain ]]; then +2026-08-28T11:49:50.2715211Z  # GitHub does not enforce `required: true` inputs itself. https://github.com/actions/runner/issues/1070 +2026-08-28T11:49:50.2715975Z  echo "'toolchain' is a required input" >&2 +2026-08-28T11:49:50.2716408Z  exit 1 +2026-08-28T11:49:50.2716873Z elif [[ $toolchain =~ ^stable' '[0-9]+' '(year|month|week|day)s?' 'ago$ ]]; then +2026-08-28T11:49:50.2717475Z  if [[ Linux == macOS ]]; then +2026-08-28T11:49:50.2718166Z  echo "toolchain=1.$((($(date -v-$(sed 's/stable \([0-9]*\) \(.\).*/\1\2/' <<< $toolchain) +%s)/60/60/24-16569)/7/6))" >> $GITHUB_OUTPUT +2026-08-28T11:49:50.2718841Z  else +2026-08-28T11:49:50.2719587Z  echo "toolchain=1.$((($(date --date "${toolchain#stable }" +%s)/60/60/24-16569)/7/6))" >> $GITHUB_OUTPUT +2026-08-28T11:49:50.2720211Z  fi +2026-08-28T11:49:50.2720692Z elif [[ $toolchain =~ ^stable' 'minus' '[0-9]+' 'releases?$ ]]; then +2026-08-28T11:49:50.2721379Z  echo "toolchain=1.$((($(date +%s)/60/60/24-16569)/7/6-${toolchain//[^0-9]/}))" >> $GITHUB_OUTPUT +2026-08-28T11:49:50.2722213Z elif [[ $toolchain =~ ^1\.[0-9]+$ ]]; then +2026-08-28T11:49:50.2722917Z  echo "toolchain=1.$((i=${toolchain#1.}, c=($(date +%s)/60/60/24-16569)/7/6, i+9*i*(10*i<=c)+90*i*(100*i<=c)))" >> $GITHUB_OUTPUT +2026-08-28T11:49:50.2723584Z else +2026-08-28T11:49:50.2723973Z  echo "toolchain=$toolchain" >> $GITHUB_OUTPUT +2026-08-28T11:49:50.2724405Z fi +2026-08-28T11:49:50.2767489Z shell: /usr/bin/bash --noprofile --norc -e -o pipefail {0} +2026-08-28T11:49:50.2767992Z env: +2026-08-28T11:49:50.2771353Z GITHUB_TOKEN: *** +2026-08-28T11:49:50.2771705Z CI_VERBOSE: false +2026-08-28T11:49:50.2772029Z toolchain: stable +2026-08-28T11:49:50.2772356Z ##[endgroup] +2026-08-28T11:49:50.2889754Z ##[end-action id=__dtolnay_rust-toolchain.parse;outcome=success;conclusion=success;duration_ms=23] +2026-08-28T11:49:50.2896784Z ##[start-action display=Construct rustup command line;id=__dtolnay_rust-toolchain.flags] +2026-08-28T11:49:50.2928092Z ##[group]Run echo "targets=$(for t in ${targets//,/ }; do echo -n ' --target' $t; done)" >> $GITHUB_OUTPUT +2026-08-28T11:49:50.2928923Z echo "targets=$(for t in ${targets//,/ }; do echo -n ' --target' $t; done)" >> $GITHUB_OUTPUT +2026-08-28T11:49:50.2930013Z echo "components=$(for c in ${components//,/ }; do echo -n ' --component' $c; done)" >> $GITHUB_OUTPUT +2026-08-28T11:49:50.2930670Z echo "downgrade=" >> $GITHUB_OUTPUT +2026-08-28T11:49:50.2968103Z shell: /usr/bin/bash --noprofile --norc -e -o pipefail {0} +2026-08-28T11:49:50.2968582Z env: +2026-08-28T11:49:50.2971765Z GITHUB_TOKEN: *** +2026-08-28T11:49:50.2972123Z CI_VERBOSE: false +2026-08-28T11:49:50.2972445Z targets: +2026-08-28T11:49:50.2972744Z components: +2026-08-28T11:49:50.2973058Z ##[endgroup] +2026-08-28T11:49:50.3048787Z ##[end-action id=__dtolnay_rust-toolchain.flags;outcome=success;conclusion=success;duration_ms=15] +2026-08-28T11:49:50.3052044Z ##[start-action display=Set $CARGO_HOME;id=__dtolnay_rust-toolchain.__run] +2026-08-28T11:49:50.3077361Z ##[group]Run echo CARGO_HOME=${CARGO_HOME:-"$HOME/.cargo"} >> $GITHUB_ENV +2026-08-28T11:49:50.3078010Z echo CARGO_HOME=${CARGO_HOME:-"$HOME/.cargo"} >> $GITHUB_ENV +2026-08-28T11:49:50.3115334Z shell: /usr/bin/bash --noprofile --norc -e -o pipefail {0} +2026-08-28T11:49:50.3116021Z env: +2026-08-28T11:49:50.3119342Z GITHUB_TOKEN: *** +2026-08-28T11:49:50.3119713Z CI_VERBOSE: false +2026-08-28T11:49:50.3120042Z ##[endgroup] +2026-08-28T11:49:50.3191212Z ##[end-action id=__dtolnay_rust-toolchain.__run;outcome=success;conclusion=success;duration_ms=13] +2026-08-28T11:49:50.3194017Z ##[start-action display=Install rustup if needed;id=__dtolnay_rust-toolchain.__run_2] +2026-08-28T11:49:50.3224668Z ##[group]Run if ! command -v rustup &>/dev/null; then +2026-08-28T11:49:50.3225177Z if ! command -v rustup &>/dev/null; then +2026-08-28T11:49:50.3226123Z  curl --proto '=https' --tlsv1.2 --retry 10 --retry-connrefused --location --silent --show-error --fail https://sh.rustup.rs | sh -s -- --default-toolchain none -y +2026-08-28T11:49:50.3227088Z  echo "$CARGO_HOME/bin" >> $GITHUB_PATH +2026-08-28T11:49:50.3227489Z fi +2026-08-28T11:49:50.3266655Z shell: /usr/bin/bash --noprofile --norc -e -o pipefail {0} +2026-08-28T11:49:50.3267127Z env: +2026-08-28T11:49:50.3270577Z GITHUB_TOKEN: *** +2026-08-28T11:49:50.3270935Z CI_VERBOSE: false +2026-08-28T11:49:50.3271271Z CARGO_HOME: /home/runner/.cargo +2026-08-28T11:49:50.3271633Z ##[endgroup] +2026-08-28T11:49:50.3341288Z ##[end-action id=__dtolnay_rust-toolchain.__run_2;outcome=success;conclusion=success;duration_ms=14] +2026-08-28T11:49:50.3344123Z ##[start-action display=Install rustup if needed on windows;id=__dtolnay_rust-toolchain.__run_3] +2026-08-28T11:49:50.3352603Z ##[end-action id=__dtolnay_rust-toolchain.__run_3;outcome=skipped;conclusion=skipped;duration_ms=0] +2026-08-28T11:49:50.3365014Z ##[start-action display=rustup toolchain install stable;id=__dtolnay_rust-toolchain.__run_4] +2026-08-28T11:49:50.3477483Z ##[group]Run rustup toolchain install stable --profile minimal --no-self-update +2026-08-28T11:49:50.3478818Z rustup toolchain install stable --profile minimal --no-self-update +2026-08-28T11:49:50.3521226Z shell: /usr/bin/bash --noprofile --norc -e -o pipefail {0} +2026-08-28T11:49:50.3521719Z env: +2026-08-28T11:49:50.3524773Z GITHUB_TOKEN: *** +2026-08-28T11:49:50.3525106Z CI_VERBOSE: false +2026-08-28T11:49:50.3525441Z CARGO_HOME: /home/runner/.cargo +2026-08-28T11:49:50.3525821Z RUSTUP_PERMIT_COPY_RENAME: 1 +2026-08-28T11:49:50.3526177Z ##[endgroup] +2026-08-28T11:49:50.5613749Z info: syncing channel updates for stable-x86_64-unknown-linux-gnu +2026-08-28T11:49:50.6382308Z +2026-08-28T11:49:50.6468163Z stable-x86_64-unknown-linux-gnu unchanged - rustc 1.98.0 (88d9e12ae 2026-08-18) +2026-08-28T11:49:50.6468769Z +2026-08-28T11:49:50.6501024Z ##[end-action id=__dtolnay_rust-toolchain.__run_4;outcome=success;conclusion=success;duration_ms=313] +2026-08-28T11:49:50.6507424Z ##[start-action display=rustup default stable;id=__dtolnay_rust-toolchain.__run_5] +2026-08-28T11:49:50.6537024Z ##[group]Run rustup default stable +2026-08-28T11:49:50.6537446Z rustup default stable +2026-08-28T11:49:50.6580117Z shell: /usr/bin/bash --noprofile --norc -e -o pipefail {0} +2026-08-28T11:49:50.6580670Z env: +2026-08-28T11:49:50.6583794Z GITHUB_TOKEN: *** +2026-08-28T11:49:50.6584129Z CI_VERBOSE: false +2026-08-28T11:49:50.6584470Z CARGO_HOME: /home/runner/.cargo +2026-08-28T11:49:50.6584828Z ##[endgroup] +2026-08-28T11:49:50.6705548Z info: using existing install for stable-x86_64-unknown-linux-gnu +2026-08-28T11:49:50.6713832Z info: default toolchain set to stable-x86_64-unknown-linux-gnu +2026-08-28T11:49:50.6714261Z +2026-08-28T11:49:50.6795800Z stable-x86_64-unknown-linux-gnu unchanged - rustc 1.98.0 (88d9e12ae 2026-08-18) +2026-08-28T11:49:50.6796559Z +2026-08-28T11:49:50.6813154Z ##[end-action id=__dtolnay_rust-toolchain.__run_5;outcome=success;conclusion=success;duration_ms=30] +2026-08-28T11:49:50.6816324Z ##[start-action display=Create cachekey;id=__dtolnay_rust-toolchain.rustc-version] +2026-08-28T11:49:50.6845809Z ##[group]Run DATE=$(rustc +stable --version --verbose | sed -ne 's/^commit-date: \(20[0-9][0-9]\)-\([01][0-9]\)-\([0-3][0-9]\)$/\1\2\3/p') +2026-08-28T11:49:50.6847050Z DATE=$(rustc +stable --version --verbose | sed -ne 's/^commit-date: \(20[0-9][0-9]\)-\([01][0-9]\)-\([0-3][0-9]\)$/\1\2\3/p') +2026-08-28T11:49:50.6847878Z HASH=$(rustc +stable --version --verbose | sed -ne 's/^commit-hash: //p') +2026-08-28T11:49:50.6848536Z echo "cachekey=$(echo $DATE$HASH | head -c12)" >> $GITHUB_OUTPUT +2026-08-28T11:49:50.6890668Z shell: /usr/bin/bash --noprofile --norc -e -o pipefail {0} +2026-08-28T11:49:50.6891143Z env: +2026-08-28T11:49:50.6894163Z GITHUB_TOKEN: *** +2026-08-28T11:49:50.6894496Z CI_VERBOSE: false +2026-08-28T11:49:50.6894885Z CARGO_HOME: /home/runner/.cargo +2026-08-28T11:49:50.6895272Z ##[endgroup] +2026-08-28T11:49:50.7286991Z ##[end-action id=__dtolnay_rust-toolchain.rustc-version;outcome=success;conclusion=success;duration_ms=46] +2026-08-28T11:49:50.7290713Z ##[start-action display=Disable incremental compilation;id=__dtolnay_rust-toolchain.__run_6] +2026-08-28T11:49:50.7317653Z ##[group]Run if [ -z "${CARGO_INCREMENTAL+set}" ]; then +2026-08-28T11:49:50.7318199Z if [ -z "${CARGO_INCREMENTAL+set}" ]; then +2026-08-28T11:49:50.7318672Z  echo CARGO_INCREMENTAL=0 >> $GITHUB_ENV +2026-08-28T11:49:50.7319360Z fi +2026-08-28T11:49:50.7361574Z shell: /usr/bin/bash --noprofile --norc -e -o pipefail {0} +2026-08-28T11:49:50.7362114Z env: +2026-08-28T11:49:50.7365149Z GITHUB_TOKEN: *** +2026-08-28T11:49:50.7365492Z CI_VERBOSE: false +2026-08-28T11:49:50.7365834Z CARGO_HOME: /home/runner/.cargo +2026-08-28T11:49:50.7366208Z ##[endgroup] +2026-08-28T11:49:50.7437074Z ##[end-action id=__dtolnay_rust-toolchain.__run_6;outcome=success;conclusion=success;duration_ms=14] +2026-08-28T11:49:50.7440451Z ##[start-action display=Enable colors in Cargo output;id=__dtolnay_rust-toolchain.__run_7] +2026-08-28T11:49:50.7468582Z ##[group]Run if [ -z "${CARGO_TERM_COLOR+set}" ]; then +2026-08-28T11:49:50.7469396Z if [ -z "${CARGO_TERM_COLOR+set}" ]; then +2026-08-28T11:49:50.7469890Z  echo CARGO_TERM_COLOR=always >> $GITHUB_ENV +2026-08-28T11:49:50.7470314Z fi +2026-08-28T11:49:50.7511045Z shell: /usr/bin/bash --noprofile --norc -e -o pipefail {0} +2026-08-28T11:49:50.7511514Z env: +2026-08-28T11:49:50.7514524Z GITHUB_TOKEN: *** +2026-08-28T11:49:50.7514852Z CI_VERBOSE: false +2026-08-28T11:49:50.7515188Z CARGO_HOME: /home/runner/.cargo +2026-08-28T11:49:50.7515556Z CARGO_INCREMENTAL: 0 +2026-08-28T11:49:50.7515885Z ##[endgroup] +2026-08-28T11:49:50.7585857Z ##[end-action id=__dtolnay_rust-toolchain.__run_7;outcome=success;conclusion=success;duration_ms=14] +2026-08-28T11:49:50.7588639Z ##[start-action display=Enable Cargo sparse registry;id=__dtolnay_rust-toolchain.__run_8] +2026-08-28T11:49:50.7616521Z ##[group]Run # implemented in 1.66, stabilized in 1.68, made default in 1.70 +2026-08-28T11:49:50.7617163Z # implemented in 1.66, stabilized in 1.68, made default in 1.70 +2026-08-28T11:49:50.7618047Z if [ -z "${CARGO_REGISTRIES_CRATES_IO_PROTOCOL+set}" -o -f "/home/runner/work/_temp"/.implicit_cargo_registries_crates_io_protocol ]; then +2026-08-28T11:49:50.7618927Z  if rustc +stable --version --verbose | grep -q '^release: 1\.6[89]\.'; then +2026-08-28T11:49:50.7620145Z  touch "/home/runner/work/_temp"/.implicit_cargo_registries_crates_io_protocol || true +2026-08-28T11:49:50.7620840Z  echo CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse >> $GITHUB_ENV +2026-08-28T11:49:50.7621482Z  elif rustc +stable --version --verbose | grep -q '^release: 1\.6[67]\.'; then +2026-08-28T11:49:50.7622220Z  touch "/home/runner/work/_temp"/.implicit_cargo_registries_crates_io_protocol || true +2026-08-28T11:49:50.7622895Z  echo CARGO_REGISTRIES_CRATES_IO_PROTOCOL=git >> $GITHUB_ENV +2026-08-28T11:49:50.7623359Z  fi +2026-08-28T11:49:50.7623650Z fi +2026-08-28T11:49:50.7663638Z shell: /usr/bin/bash --noprofile --norc -e -o pipefail {0} +2026-08-28T11:49:50.7664099Z env: +2026-08-28T11:49:50.7667192Z GITHUB_TOKEN: *** +2026-08-28T11:49:50.7667695Z CI_VERBOSE: false +2026-08-28T11:49:50.7668033Z CARGO_HOME: /home/runner/.cargo +2026-08-28T11:49:50.7668396Z CARGO_INCREMENTAL: 0 +2026-08-28T11:49:50.7668738Z CARGO_TERM_COLOR: always +2026-08-28T11:49:50.7669365Z ##[endgroup] +2026-08-28T11:49:50.8020469Z ##[end-action id=__dtolnay_rust-toolchain.__run_8;outcome=success;conclusion=success;duration_ms=43] +2026-08-28T11:49:50.8023869Z ##[start-action display=Work around spurious network errors in curl 8.0;id=__dtolnay_rust-toolchain.__run_9] +2026-08-28T11:49:50.8053113Z ##[group]Run # https://rust-lang.zulipchat.com/#narrow/stream/246057-t-cargo/topic/timeout.20investigation +2026-08-28T11:49:50.8054051Z # https://rust-lang.zulipchat.com/#narrow/stream/246057-t-cargo/topic/timeout.20investigation +2026-08-28T11:49:50.8054801Z if rustc +stable --version --verbose | grep -q '^release: 1\.7[01]\.'; then +2026-08-28T11:49:50.8055399Z  echo CARGO_HTTP_MULTIPLEXING=false >> $GITHUB_ENV +2026-08-28T11:49:50.8055824Z fi +2026-08-28T11:49:50.8097650Z shell: /usr/bin/bash --noprofile --norc -e -o pipefail {0} +2026-08-28T11:49:50.8098114Z env: +2026-08-28T11:49:50.8101490Z GITHUB_TOKEN: *** +2026-08-28T11:49:50.8101831Z CI_VERBOSE: false +2026-08-28T11:49:50.8102163Z CARGO_HOME: /home/runner/.cargo +2026-08-28T11:49:50.8102530Z CARGO_INCREMENTAL: 0 +2026-08-28T11:49:50.8102860Z CARGO_TERM_COLOR: always +2026-08-28T11:49:50.8103186Z ##[endgroup] +2026-08-28T11:49:50.8311121Z ##[end-action id=__dtolnay_rust-toolchain.__run_9;outcome=success;conclusion=success;duration_ms=28] +2026-08-28T11:49:50.8315804Z ##[start-action display=rustc +stable --version --verbose;id=__dtolnay_rust-toolchain.__run_10] +2026-08-28T11:49:50.8343169Z ##[group]Run rustc +stable --version --verbose +2026-08-28T11:49:50.8343634Z rustc +stable --version --verbose +2026-08-28T11:49:50.8383544Z shell: /usr/bin/bash --noprofile --norc -e -o pipefail {0} +2026-08-28T11:49:50.8384008Z env: +2026-08-28T11:49:50.8387283Z GITHUB_TOKEN: *** +2026-08-28T11:49:50.8387643Z CI_VERBOSE: false +2026-08-28T11:49:50.8387985Z CARGO_HOME: /home/runner/.cargo +2026-08-28T11:49:50.8388348Z CARGO_INCREMENTAL: 0 +2026-08-28T11:49:50.8388691Z CARGO_TERM_COLOR: always +2026-08-28T11:49:50.8389474Z ##[endgroup] +2026-08-28T11:49:50.8572772Z rustc 1.98.0 (88d9e12ae 2026-08-18) +2026-08-28T11:49:50.8573738Z binary: rustc +2026-08-28T11:49:50.8574289Z commit-hash: 88d9e12ae178fab0fb5cc050a94da85685d449ea +2026-08-28T11:49:50.8574862Z commit-date: 2026-08-18 +2026-08-28T11:49:50.8575399Z host: x86_64-unknown-linux-gnu +2026-08-28T11:49:50.8576250Z release: 1.98.0 +2026-08-28T11:49:50.8576977Z LLVM version: 22.1.8 +2026-08-28T11:49:50.8597175Z ##[end-action id=__dtolnay_rust-toolchain.__run_10;outcome=success;conclusion=success;duration_ms=28] +2026-08-28T11:49:50.8740559Z ##[group]Run Swatinem/rust-cache@v2 +2026-08-28T11:49:50.8741000Z with: +2026-08-28T11:49:50.8741294Z workspaces: rust +2026-08-28T11:49:50.8741611Z prefix-key: v0-rust +2026-08-28T11:49:50.8741949Z add-job-id-key: true +2026-08-28T11:49:50.8742300Z add-rust-environment-hash-key: true +2026-08-28T11:49:50.8742683Z cache-targets: true +2026-08-28T11:49:50.8743022Z cache-all-crates: false +2026-08-28T11:49:50.8743379Z cache-workspace-crates: false +2026-08-28T11:49:50.8743727Z save-if: true +2026-08-28T11:49:50.8744037Z cache-provider: github +2026-08-28T11:49:50.8744365Z cache-bin: true +2026-08-28T11:49:50.8744665Z lookup-only: false +2026-08-28T11:49:50.8744972Z cmd-format: {0} +2026-08-28T11:49:50.8745262Z env: +2026-08-28T11:49:50.8748327Z GITHUB_TOKEN: *** +2026-08-28T11:49:50.8748648Z CI_VERBOSE: false +2026-08-28T11:49:50.8749242Z CARGO_HOME: /home/runner/.cargo +2026-08-28T11:49:50.8749674Z CARGO_INCREMENTAL: 0 +2026-08-28T11:49:50.8750056Z CARGO_TERM_COLOR: always +2026-08-28T11:49:50.8750386Z ##[endgroup] +2026-08-28T11:49:52.1708796Z ##[group]Cache Configuration +2026-08-28T11:49:52.1709461Z Cache Provider: +2026-08-28T11:49:52.1709954Z github +2026-08-28T11:49:52.1710304Z Workspaces: +2026-08-28T11:49:52.1710801Z /home/runner/work/links-notation/links-notation/rust +2026-08-28T11:49:52.1711354Z Cache Paths: +2026-08-28T11:49:52.1711721Z /home/runner/.cargo/bin +2026-08-28T11:49:52.1712198Z /home/runner/.cargo/.crates.toml +2026-08-28T11:49:52.1712730Z /home/runner/.cargo/.crates2.json +2026-08-28T11:49:52.1713204Z /home/runner/.cargo/registry +2026-08-28T11:49:52.1713645Z /home/runner/.cargo/git +2026-08-28T11:49:52.1714202Z /home/runner/work/links-notation/links-notation/rust/target +2026-08-28T11:49:52.1714791Z Restore Key: +2026-08-28T11:49:52.1715240Z v0-rust-publishToCratesIO-Linux-x64-0b9fd15e +2026-08-28T11:49:52.1715820Z Cache Key: +2026-08-28T11:49:52.1716337Z v0-rust-publishToCratesIO-Linux-x64-0b9fd15e-2cd05178 +2026-08-28T11:49:52.1716935Z .. Prefix: +2026-08-28T11:49:52.1717329Z - v0-rust-publishToCratesIO-Linux-x64 +2026-08-28T11:49:52.1717910Z .. Environment considered: +2026-08-28T11:49:52.1718217Z - Rust Versions: +2026-08-28T11:49:52.1718595Z - 1.98.0 x86_64-unknown-linux-gnu 88d9e12ae178fab0fb5cc050a94da85685d449ea +2026-08-28T11:49:52.1719303Z - CARGO_HOME +2026-08-28T11:49:52.1719634Z - CARGO_INCREMENTAL +2026-08-28T11:49:52.1719885Z - CARGO_TERM_COLOR +2026-08-28T11:49:52.1720129Z .. Lockfiles considered: +2026-08-28T11:49:52.1720588Z - /home/runner/work/links-notation/links-notation/rust/links-notation-macro/Cargo.toml +2026-08-28T11:49:52.1721274Z - /home/runner/work/links-notation/links-notation/rust/links-notation/Cargo.toml +2026-08-28T11:49:52.1721972Z ##[endgroup] +2026-08-28T11:49:52.1722161Z +2026-08-28T11:49:52.1722298Z ... Restoring cache ... +2026-08-28T11:49:52.4201150Z Cache hit for: v0-rust-publishToCratesIO-Linux-x64-0b9fd15e-2cd05178 +2026-08-28T11:49:53.6824112Z Received 632057 of 9020665 (7.0%), 0.6 MBs/sec +2026-08-28T11:49:53.8205105Z Received 9020665 of 9020665 (100.0%), 7.6 MBs/sec +2026-08-28T11:49:53.8205780Z Cache Size: ~9 MB (9020665 B) +2026-08-28T11:49:53.8238686Z [command]/usr/bin/tar -xf /home/runner/work/_temp/563948bf-c720-428f-8d5e-73d49e99a8f8/cache.tzst -P -C /home/runner/work/links-notation/links-notation --use-compress-program unzstd +2026-08-28T11:49:53.8820213Z Cache restored successfully +2026-08-28T11:49:53.8827348Z Restored from cache key "v0-rust-publishToCratesIO-Linux-x64-0b9fd15e-2cd05178" full match: true. +2026-08-28T11:49:53.8930373Z ##[group]Run cargo build --release +2026-08-28T11:49:53.8930718Z cargo build --release +2026-08-28T11:49:53.8967238Z shell: /usr/bin/bash -e {0} +2026-08-28T11:49:53.8967518Z env: +2026-08-28T11:49:53.8970664Z GITHUB_TOKEN: *** +2026-08-28T11:49:53.8970917Z CI_VERBOSE: false +2026-08-28T11:49:53.8971165Z CARGO_HOME: /home/runner/.cargo +2026-08-28T11:49:53.8971425Z CARGO_INCREMENTAL: 0 +2026-08-28T11:49:53.8971648Z CARGO_TERM_COLOR: always +2026-08-28T11:49:53.8971890Z CACHE_ON_FAILURE: false +2026-08-28T11:49:53.8972115Z ##[endgroup] +2026-08-28T11:49:54.3096433Z  Updating crates.io index +2026-08-28T11:49:54.5237956Z  Locking 6 packages to latest compatible versions +2026-08-28T11:49:54.6702072Z  Compiling links-notation-macro v0.1.0 (/home/runner/work/links-notation/links-notation/rust/links-notation-macro) +2026-08-28T11:50:00.8996160Z  Compiling links-notation v0.16.0 (/home/runner/work/links-notation/links-notation/rust/links-notation) +2026-08-28T11:50:02.6976138Z  Finished `release` profile [optimized] target(s) in 8.78s +2026-08-28T11:50:02.7168703Z ##[group]Run rust-lang/crates-io-auth-action@v1 +2026-08-28T11:50:02.7169348Z with: +2026-08-28T11:50:02.7169570Z url: https://crates.io +2026-08-28T11:50:02.7169830Z env: +2026-08-28T11:50:02.7172661Z GITHUB_TOKEN: *** +2026-08-28T11:50:02.7172892Z CI_VERBOSE: false +2026-08-28T11:50:02.7173124Z CARGO_HOME: /home/runner/.cargo +2026-08-28T11:50:02.7173387Z CARGO_INCREMENTAL: 0 +2026-08-28T11:50:02.7173615Z CARGO_TERM_COLOR: always +2026-08-28T11:50:02.7173855Z CACHE_ON_FAILURE: false +2026-08-28T11:50:02.7174081Z ##[endgroup] +2026-08-28T11:50:02.7941333Z Retrieving GitHub Actions JWT token with audience: crates.io +2026-08-28T11:50:03.0376617Z Retrieved JWT token successfully +2026-08-28T11:50:03.0377719Z Requesting token from: https://crates.io/api/v1/trusted_publishing/tokens. User agent: crates-io-auth-action/1.0.1 +2026-08-28T11:50:03.1683205Z ##[error]Failed to retrieve token from Cargo registry. Status: 400. Error: No Trusted Publishing config found for repository `link-foundation/links-notation`. +2026-08-28T11:50:03.2117635Z ##[group]Run set -euo pipefail +2026-08-28T11:50:03.2118041Z set -euo pipefail +2026-08-28T11:50:03.2118296Z  +2026-08-28T11:50:03.2118628Z read_field() { grep "^$2 = " "$1" | head -1 | sed "s/$2 = \"\(.*\)\"/\1/"; } +2026-08-28T11:50:03.2119510Z MACRO_VERSION=$(read_field links-notation-macro/Cargo.toml version) +2026-08-28T11:50:03.2120016Z PACKAGE_NAME=$(read_field links-notation/Cargo.toml name) +2026-08-28T11:50:03.2120474Z PACKAGE_VERSION=$(read_field links-notation/Cargo.toml version) +2026-08-28T11:50:03.2120904Z echo "name=$PACKAGE_NAME" >> "$GITHUB_OUTPUT" +2026-08-28T11:50:03.2121283Z echo "version=$PACKAGE_VERSION" >> "$GITHUB_OUTPUT" +2026-08-28T11:50:03.2121611Z  +2026-08-28T11:50:03.2121929Z # The token never appears on the command line, where it would be +2026-08-28T11:50:03.2122387Z # visible to every other process on the runner via /proc. +2026-08-28T11:50:03.2122858Z export CARGO_REGISTRY_TOKEN="${OIDC_TOKEN:-$CARGO_TOKEN}" +2026-08-28T11:50:03.2123230Z if [ -n "${OIDC_TOKEN:-}" ]; then +2026-08-28T11:50:03.2123781Z  echo "crates.io credential: trusted publishing (OIDC)" +2026-08-28T11:50:03.2124149Z elif [ -n "${CARGO_TOKEN:-}" ]; then +2026-08-28T11:50:03.2124508Z  echo "crates.io credential: CARGO_TOKEN present" +2026-08-28T11:50:03.2124840Z else +2026-08-28T11:50:03.2125091Z  echo "published=failed" >> "$GITHUB_OUTPUT" +2026-08-28T11:50:03.2125765Z  echo "::warning::No crates.io credential (neither trusted publishing nor CARGO_TOKEN); skipping publish. No GitHub release will be created." +2026-08-28T11:50:03.2126384Z  exit 0 +2026-08-28T11:50:03.2126587Z fi +2026-08-28T11:50:03.2126782Z  +2026-08-28T11:50:03.2126980Z publish_crate() { +2026-08-28T11:50:03.2127248Z  local crate="$1" version="$2" log="$3" +2026-08-28T11:50:03.2127619Z  if cargo publish -p "$crate" 2>&1 | tee "$log"; then +2026-08-28T11:50:03.2127976Z  echo "Published ${crate}@${version}" +2026-08-28T11:50:03.2128274Z  return 0 +2026-08-28T11:50:03.2128485Z  fi +2026-08-28T11:50:03.2128768Z  if grep -q "already exists on crates.io index" "$log"; then +2026-08-28T11:50:03.2129482Z  echo "${crate}@${version} is already on crates.io" +2026-08-28T11:50:03.2129817Z  return 2 +2026-08-28T11:50:03.2130029Z  fi +2026-08-28T11:50:03.2130316Z  echo "::error::Failed to publish ${crate}@${version}" +2026-08-28T11:50:03.2130651Z  return 1 +2026-08-28T11:50:03.2130863Z } +2026-08-28T11:50:03.2131050Z  +2026-08-28T11:50:03.2131357Z # The main crate depends on links-notation-macro, so that one has to +2026-08-28T11:50:03.2131793Z # land, and be visible in the index, first. +2026-08-28T11:50:03.2132089Z set +e +2026-08-28T11:50:03.2132435Z publish_crate links-notation-macro "$MACRO_VERSION" macro_publish.log +2026-08-28T11:50:03.2132848Z MACRO_STATUS=$? +2026-08-28T11:50:03.2133085Z set -e +2026-08-28T11:50:03.2133333Z if [ $MACRO_STATUS -eq 1 ]; then exit 1; fi +2026-08-28T11:50:03.2133623Z  +2026-08-28T11:50:03.2133825Z if [ $MACRO_STATUS -eq 0 ]; then +2026-08-28T11:50:03.2134116Z  for attempt in $(seq 1 20); do +2026-08-28T11:50:03.2134666Z  if curl -fsS "https://crates.io/api/v1/crates/links-notation-macro/${MACRO_VERSION}" >/dev/null 2>&1; then +2026-08-28T11:50:03.2135350Z  echo "links-notation-macro@${MACRO_VERSION} is visible in the index" +2026-08-28T11:50:03.2135750Z  break +2026-08-28T11:50:03.2135964Z  fi +2026-08-28T11:50:03.2136289Z  echo "Waiting for the index to catch up (attempt ${attempt}/20)" +2026-08-28T11:50:03.2136664Z  sleep 15 +2026-08-28T11:50:03.2137051Z  done +2026-08-28T11:50:03.2137266Z fi +2026-08-28T11:50:03.2137458Z  +2026-08-28T11:50:03.2137644Z set +e +2026-08-28T11:50:03.2137971Z publish_crate "$PACKAGE_NAME" "$PACKAGE_VERSION" publish.log +2026-08-28T11:50:03.2138349Z STATUS=$? +2026-08-28T11:50:03.2138566Z set -e +2026-08-28T11:50:03.2138770Z case $STATUS in +2026-08-28T11:50:03.2139317Z  0) echo "published=true" >> "$GITHUB_OUTPUT" ;; +2026-08-28T11:50:03.2139722Z  2) echo "published=skipped" >> "$GITHUB_OUTPUT" ;; +2026-08-28T11:50:03.2140123Z  *) echo "published=failed" >> "$GITHUB_OUTPUT"; exit 1 ;; +2026-08-28T11:50:03.2140458Z esac +2026-08-28T11:50:03.2176144Z shell: /usr/bin/bash -e {0} +2026-08-28T11:50:03.2176419Z env: +2026-08-28T11:50:03.2179482Z GITHUB_TOKEN: *** +2026-08-28T11:50:03.2179730Z CI_VERBOSE: false +2026-08-28T11:50:03.2179969Z CARGO_HOME: /home/runner/.cargo +2026-08-28T11:50:03.2180244Z CARGO_INCREMENTAL: 0 +2026-08-28T11:50:03.2180483Z CARGO_TERM_COLOR: always +2026-08-28T11:50:03.2180727Z CACHE_ON_FAILURE: false +2026-08-28T11:50:03.2180952Z OIDC_TOKEN: +2026-08-28T11:50:03.2181386Z CARGO_TOKEN: *** +2026-08-28T11:50:03.2181599Z ##[endgroup] +2026-08-28T11:50:03.2308506Z crates.io credential: CARGO_TOKEN present +2026-08-28T11:50:03.2591713Z  Updating crates.io index +2026-08-28T11:50:03.3076685Z error: crate links-notation-macro@0.1.0 already exists on crates.io index +2026-08-28T11:50:03.3109455Z links-notation-macro@0.1.0 is already on crates.io +2026-08-28T11:50:03.3283292Z  Updating crates.io index +2026-08-28T11:50:03.6020062Z  Packaging links-notation v0.16.0 (/home/runner/work/links-notation/links-notation/rust/links-notation) +2026-08-28T11:50:03.6033457Z  Updating crates.io index +2026-08-28T11:50:03.6216013Z  Packaged 30 files, 254.0KiB (41.2KiB compressed) +2026-08-28T11:50:03.6217132Z  Verifying links-notation v0.16.0 (/home/runner/work/links-notation/links-notation/rust/links-notation) +2026-08-28T11:50:03.6267028Z  Downloading crates ... +2026-08-28T11:50:03.6809320Z  Downloaded links-notation-macro v0.1.0 +2026-08-28T11:50:03.6864543Z  Compiling proc-macro2 v1.0.107 +2026-08-28T11:50:03.6872264Z  Compiling unicode-ident v1.0.24 +2026-08-28T11:50:03.6873863Z  Compiling quote v1.0.47 +2026-08-28T11:50:03.6875011Z  Compiling memchr v2.8.3 +2026-08-28T11:50:04.1551883Z  Compiling nom v8.0.0 +2026-08-28T11:50:05.0041650Z  Compiling syn v3.0.4 +2026-08-28T11:50:07.2394554Z  Compiling links-notation-macro v0.1.0 +2026-08-28T11:50:07.4595748Z  Compiling links-notation v0.16.0 (/home/runner/work/links-notation/links-notation/rust/target/package/links-notation-0.16.0) +2026-08-28T11:50:07.8948025Z  Finished `dev` profile [unoptimized + debuginfo] target(s) in 4.57s +2026-08-28T11:50:07.8960054Z  Uploading links-notation v0.16.0 (/home/runner/work/links-notation/links-notation/rust/links-notation) +2026-08-28T11:50:08.6497339Z  Uploaded links-notation v0.16.0 to registry `crates-io` +2026-08-28T11:50:08.6500222Z note: waiting for links-notation v0.16.0 to be available at registry `crates-io` +2026-08-28T11:50:08.6501365Z help: you may press ctrl-c to skip waiting; the crate should be available shortly +2026-08-28T11:50:09.6925722Z  Published links-notation v0.16.0 at registry `crates-io` +2026-08-28T11:50:09.6972581Z Published links-notation@0.16.0 +2026-08-28T11:50:09.7026217Z ##[group]Run set -euo pipefail +2026-08-28T11:50:09.7026574Z set -euo pipefail +2026-08-28T11:50:09.7026905Z for attempt in $(seq 1 20); do +2026-08-28T11:50:09.7027437Z  if curl -fsS "https://crates.io/api/v1/crates/${PACKAGE_NAME}/${PACKAGE_VERSION}" >/dev/null 2>&1; then +2026-08-28T11:50:09.7028145Z  echo "Verified ${PACKAGE_NAME}@${PACKAGE_VERSION} on crates.io (attempt ${attempt})" +2026-08-28T11:50:09.7028593Z  exit 0 +2026-08-28T11:50:09.7028824Z  fi +2026-08-28T11:50:09.7029469Z  echo "Not visible yet, retrying in 15s (attempt ${attempt}/20)" +2026-08-28T11:50:09.7029846Z  sleep 15 +2026-08-28T11:50:09.7030065Z done +2026-08-28T11:50:09.7030467Z echo "::error::${PACKAGE_NAME}@${PACKAGE_VERSION} did not appear on crates.io within 5 minutes" +2026-08-28T11:50:09.7030924Z exit 1 +2026-08-28T11:50:09.7067054Z shell: /usr/bin/bash -e {0} +2026-08-28T11:50:09.7067348Z env: +2026-08-28T11:50:09.7070657Z GITHUB_TOKEN: *** +2026-08-28T11:50:09.7070904Z CI_VERBOSE: false +2026-08-28T11:50:09.7071131Z CARGO_HOME: /home/runner/.cargo +2026-08-28T11:50:09.7071397Z CARGO_INCREMENTAL: 0 +2026-08-28T11:50:09.7071622Z CARGO_TERM_COLOR: always +2026-08-28T11:50:09.7071858Z CACHE_ON_FAILURE: false +2026-08-28T11:50:09.7072107Z PACKAGE_NAME: links-notation +2026-08-28T11:50:09.7072359Z PACKAGE_VERSION: 0.16.0 +2026-08-28T11:50:09.7072589Z ##[endgroup] +2026-08-28T11:50:09.7617806Z Not visible yet, retrying in 15s (attempt 1/20) +2026-08-28T11:50:24.8094849Z Not visible yet, retrying in 15s (attempt 2/20) +2026-08-28T11:50:39.8552015Z Not visible yet, retrying in 15s (attempt 3/20) +2026-08-28T11:50:54.9000517Z Not visible yet, retrying in 15s (attempt 4/20) +2026-08-28T11:51:09.9637594Z Not visible yet, retrying in 15s (attempt 5/20) +2026-08-28T11:51:25.0148472Z Not visible yet, retrying in 15s (attempt 6/20) +2026-08-28T11:51:40.0593280Z Not visible yet, retrying in 15s (attempt 7/20) +2026-08-28T11:51:55.1045271Z Not visible yet, retrying in 15s (attempt 8/20) +2026-08-28T11:52:10.1636246Z Not visible yet, retrying in 15s (attempt 9/20) +2026-08-28T11:52:25.2064152Z Not visible yet, retrying in 15s (attempt 10/20) +2026-08-28T11:52:40.4184660Z Not visible yet, retrying in 15s (attempt 11/20) +2026-08-28T11:52:55.4649509Z Not visible yet, retrying in 15s (attempt 12/20) +2026-08-28T11:53:10.5174718Z Not visible yet, retrying in 15s (attempt 13/20) +2026-08-28T11:53:25.5641034Z Not visible yet, retrying in 15s (attempt 14/20) +2026-08-28T11:53:40.6338419Z Not visible yet, retrying in 15s (attempt 15/20) +2026-08-28T11:53:55.6793895Z Not visible yet, retrying in 15s (attempt 16/20) +2026-08-28T11:54:10.7474938Z Not visible yet, retrying in 15s (attempt 17/20) +2026-08-28T11:54:25.7940489Z Not visible yet, retrying in 15s (attempt 18/20) +2026-08-28T11:54:40.8498593Z Not visible yet, retrying in 15s (attempt 19/20) +2026-08-28T11:54:55.8946488Z Not visible yet, retrying in 15s (attempt 20/20) +2026-08-28T11:55:10.8969972Z ##[error]links-notation@0.16.0 did not appear on crates.io within 5 minutes +2026-08-28T11:55:10.8973351Z ##[error]Process completed with exit code 1. +2026-08-28T11:55:10.9035792Z Post job cleanup. +2026-08-28T11:55:10.9811693Z No token to revoke +2026-08-28T11:55:10.9972723Z Post job cleanup. +2026-08-28T11:55:11.0856094Z [command]/usr/bin/git version +2026-08-28T11:55:11.0915233Z git version 2.55.0 +2026-08-28T11:55:11.0960100Z Temporarily overriding HOME='/home/runner/work/_temp/fb22e297-d34b-41e7-9ebf-29ba2028a7bd' before making global git config changes +2026-08-28T11:55:11.0961504Z Adding repository directory to the temporary git global config as a safe directory +2026-08-28T11:55:11.0967723Z [command]/usr/bin/git config --global --add safe.directory /home/runner/work/links-notation/links-notation +2026-08-28T11:55:11.1000874Z Removing SSH command configuration +2026-08-28T11:55:11.1007895Z [command]/usr/bin/git config --local --name-only --get-regexp core\.sshCommand +2026-08-28T11:55:11.1045221Z [command]/usr/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'core\.sshCommand' && git config --local --unset-all 'core.sshCommand' || :" +2026-08-28T11:55:11.1301024Z Removing HTTP extra header +2026-08-28T11:55:11.1307163Z [command]/usr/bin/git config --local --name-only --get-regexp http\.https\:\/\/github\.com\/\.extraheader +2026-08-28T11:55:11.1349263Z [command]/usr/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'http\.https\:\/\/github\.com\/\.extraheader' && git config --local --unset-all 'http.https://github.com/.extraheader' || :" +2026-08-28T11:55:11.1607181Z Removing includeIf entries pointing to credentials config files +2026-08-28T11:55:11.1616284Z [command]/usr/bin/git config --local --name-only --get-regexp ^includeIf\.gitdir: +2026-08-28T11:55:11.1656431Z [command]/usr/bin/git submodule foreach --recursive git config --local --show-origin --name-only --get-regexp remote.origin.url +2026-08-28T11:55:11.2148275Z Evaluate and set job outputs +2026-08-28T11:55:11.2155621Z Set output 'published' +2026-08-28T11:55:11.2158773Z Set output 'version' +2026-08-28T11:55:11.2160082Z Set output 'name' +2026-08-28T11:55:11.2161188Z Cleaning up orphan processes diff --git a/dev/log/issues/298/pulls/299/templates/csharp-workflows.txt b/dev/log/issues/298/pulls/299/templates/csharp-workflows.txt new file mode 100644 index 00000000..30ba50af --- /dev/null +++ b/dev/log/issues/298/pulls/299/templates/csharp-workflows.txt @@ -0,0 +1,5 @@ +docs.yml +links.yml +release.yml +security.yml +workflows.yml diff --git a/dev/log/issues/298/pulls/299/templates/csharp/docs.yml b/dev/log/issues/298/pulls/299/templates/csharp/docs.yml new file mode 100644 index 00000000..642b36aa --- /dev/null +++ b/dev/log/issues/298/pulls/299/templates/csharp/docs.yml @@ -0,0 +1,110 @@ +name: docs + +# Build and deploy DocFX API documentation to GitHub Pages. +# +# Build runs on every push to main and on PRs that touch docs, sources, or +# this workflow. Publishing is gated on `push` to `main` (and manual dispatch) +# plus an explicit DEPLOY_GITHUB_PAGES=true repository variable, so fresh +# repositories keep docs build validation without failing before Pages is +# enabled and configured. See issue #15 for the failure mode that gating on +# releases produces. + +on: + push: + branches: [main] + paths: + - 'docs/**' + - 'src/**' + - 'docfx.json' + - '.github/workflows/docs.yml' + pull_request: + branches: [main] + paths: + - 'docs/**' + - 'src/**' + - 'docfx.json' + - '.github/workflows/docs.yml' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +env: + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + DOTNET_NOLOGO: true + +jobs: + build: + name: Build documentation + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '8.0.x' + + - name: Install DocFX + run: dotnet tool update -g docfx + + - name: Restore dependencies + run: dotnet restore + + - name: Build documentation site + run: docfx docfx.json -o _site + + - name: List built site (debug) + run: | + echo "::group::_site tree" + find _site -maxdepth 3 -print + echo "::endgroup::" + + - name: Skip GitHub Pages deployment + if: | + ((github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch') && + vars.DEPLOY_GITHUB_PAGES != 'true' + run: | + echo "::notice::GitHub Pages deployment is disabled. Configure Pages, set repository variable DEPLOY_GITHUB_PAGES=true, then rerun this workflow to publish docs." + + - name: Configure GitHub Pages + if: | + ((github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch') && + vars.DEPLOY_GITHUB_PAGES == 'true' + uses: actions/configure-pages@v6 + + - name: Upload GitHub Pages artifact + if: | + ((github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch') && + vars.DEPLOY_GITHUB_PAGES == 'true' + uses: actions/upload-pages-artifact@v5 + with: + path: _site + + deploy: + name: Deploy to GitHub Pages + if: | + ((github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch') && + vars.DEPLOY_GITHUB_PAGES == 'true' + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 + + - name: Print resolved deployment URL (debug) + run: | + echo "Pages deployed to: ${{ steps.deployment.outputs.page_url }}" diff --git a/dev/log/issues/298/pulls/299/templates/csharp/links.yml b/dev/log/issues/298/pulls/299/templates/csharp/links.yml new file mode 100644 index 00000000..e685934e --- /dev/null +++ b/dev/log/issues/298/pulls/299/templates/csharp/links.yml @@ -0,0 +1,101 @@ +name: Broken Link Checker + +on: + push: + branches: + - main + paths: + - '**.md' + - '**.html' + - '.github/workflows/links.yml' + pull_request: + types: [opened, synchronize, reopened] + paths: + - '**.md' + - '**.html' + - '.github/workflows/links.yml' + workflow_dispatch: + +# Least-privilege default; jobs escalate individually when needed. +permissions: + contents: read + +# Provide Git config to actions/checkout itself; checkout runs git init before +# any workflow step can configure Git. +env: + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: main + +jobs: + link-checker: + name: Check Links + runs-on: ubuntu-latest + # Typical run: <1min with lychee cache. 10min prevents slow + # external hosts or Wayback Machine probes from hanging the workflow. + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-link-checker + cancel-in-progress: true + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + + - name: Check links with lychee + id: lychee + uses: lycheeverse/lychee-action@v2 + with: + # Check all Markdown and HTML files + # Exclude case-studies directory - these are research documents from + # external repos with references to files and issues that don't exist + # in this repository (similar exclusion pattern as eslint.config.js) + # Exclude scripts/fixtures - captured lychee reports whose links are + # deliberately broken so the parser tests have something to parse + args: >- + --verbose + --no-progress + --cache + --max-cache-age 1d + --max-retries 3 + --timeout 30 + --exclude-path docs/case-studies + --exclude-path scripts/fixtures + './**/*.md' + './**/*.html' + # Don't fail the workflow immediately - we want to check web archive first + fail: false + # Output file for broken links report (used by check-web-archive.mjs) + output: lychee/out.md + # Write a job summary + jobSummary: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Check broken links against Web Archive + if: steps.lychee.outputs.exit_code != 0 + id: webarchive + run: node scripts/check-web-archive.mjs + env: + LYCHEE_OUTPUT: lychee/out.md + + - name: Fail if broken links found and no web archive fallback + if: steps.lychee.outputs.exit_code != 0 && steps.webarchive.outputs.all_archived != 'true' + run: | + echo "::error::Broken links were detected with no Web Archive fallback available." + echo "" + echo "What happened:" + echo " lychee found one or more broken links in the *.md and *.html files of this repository." + echo " The Web Archive (Wayback Machine) check found no archived versions for some of them." + echo "" + echo "How to fix:" + echo " 1. Review the 'Check links with lychee' step above for a full list of broken links." + echo " 2. For links marked with a '::notice::' annotation above, a Web Archive version exists." + echo " Replace those broken links with the suggested archive.org URL." + echo " 3. For links with no archive version, either:" + echo " a. Find an updated URL that points to the same or equivalent content." + echo " b. Remove the link if the content is no longer relevant." + echo " c. Add the URL to .lycheeignore if it is a known false positive." + echo "" + echo "Report location: lychee/out.md (available as a workflow artifact if configured)." + exit 1 diff --git a/dev/log/issues/298/pulls/299/templates/csharp/release.yml b/dev/log/issues/298/pulls/299/templates/csharp/release.yml new file mode 100644 index 00000000..7703fc68 --- /dev/null +++ b/dev/log/issues/298/pulls/299/templates/csharp/release.yml @@ -0,0 +1,796 @@ +name: CI/CD Pipeline + +on: + push: + branches: + - main + pull_request: + types: [opened, synchronize, reopened] + workflow_dispatch: + inputs: + release_mode: + description: 'Release mode' + required: true + type: choice + default: 'instant' + options: + - instant + - changeset-pr + bump_type: + description: 'Version bump type' + required: true + type: choice + options: + - patch + - minor + - major + description: + description: 'Release description (optional)' + required: false + type: string + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +env: + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: main + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + DOTNET_NOLOGO: true + +jobs: + # === DETECT CHANGES - determines which jobs should run === + detect-changes: + name: Detect Changes + runs-on: ubuntu-latest + timeout-minutes: 5 + if: github.event_name != 'workflow_dispatch' + outputs: + any-code-changed: ${{ steps.changes.outputs.any-code-changed }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Detect C# layout + id: csharp_layout + shell: bash + run: | + set -euo pipefail + if compgen -G "*.csproj" > /dev/null || compgen -G "*.sln" > /dev/null || find src -maxdepth 5 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then + CSHARP_ROOT="." + MULTI_LANGUAGE="false" + elif find csharp -maxdepth 6 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then + CSHARP_ROOT="csharp" + MULTI_LANGUAGE="true" + else + echo "::error::Could not find a C# project at the repository root or under csharp/" + exit 1 + fi + echo "root=$CSHARP_ROOT" >> "$GITHUB_OUTPUT" + echo "multi-language=$MULTI_LANGUAGE" >> "$GITHUB_OUTPUT" + echo "Detected C# layout: root=$CSHARP_ROOT, multi-language=$MULTI_LANGUAGE" + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Detect changes + id: changes + env: + GITHUB_EVENT_NAME: ${{ github.event_name }} + GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }} + GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + CSHARP_ROOT: ${{ steps.csharp_layout.outputs.root }} + run: bun run "${{ steps.csharp_layout.outputs.root }}/scripts/detect-code-changes.mjs" + + # === CHANGESET CHECK - only runs on PRs with code changes === + # Docs-only PRs (./docs folder, markdown files) don't require changesets + changeset-check: + name: Changeset Validation + runs-on: ubuntu-latest + timeout-minutes: 10 + needs: [detect-changes] + if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any-code-changed == 'true' + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Detect C# layout + id: csharp_layout + shell: bash + run: | + set -euo pipefail + if compgen -G "*.csproj" > /dev/null || compgen -G "*.sln" > /dev/null || find src -maxdepth 5 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then + CSHARP_ROOT="." + MULTI_LANGUAGE="false" + elif find csharp -maxdepth 6 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then + CSHARP_ROOT="csharp" + MULTI_LANGUAGE="true" + else + echo "::error::Could not find a C# project at the repository root or under csharp/" + exit 1 + fi + echo "root=$CSHARP_ROOT" >> "$GITHUB_OUTPUT" + echo "multi-language=$MULTI_LANGUAGE" >> "$GITHUB_OUTPUT" + echo "Detected C# layout: root=$CSHARP_ROOT, multi-language=$MULTI_LANGUAGE" + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Validate changeset + env: + GITHUB_BASE_REF: ${{ github.base_ref }} + GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }} + GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + CSHARP_ROOT: ${{ steps.csharp_layout.outputs.root }} + GITHUB_HEAD_REF: ${{ github.head_ref }} + run: | + # Skip changeset check for automated release PRs + # Read head_ref through env, never interpolate it into the script: + # on a fork PR the branch name is attacker-controlled. + if [[ "$GITHUB_HEAD_REF" == "changeset-release/"* ]] || [[ "$GITHUB_HEAD_REF" == "changeset-manual-release-"* ]]; then + echo "Skipping changeset check for automated release PR" + exit 0 + fi + + # Run changeset validation script + bun run "$CSHARP_ROOT/scripts/validate-changeset.mjs" + + # === LINT AND FORMAT CHECK === + # Lint runs independently of changeset-check - it's a fast check that should always run + # See: https://github.com/link-foundation/js-ai-driven-development-pipeline-template/pull/18 for why this dependency was removed + lint: + name: Lint and Format Check + runs-on: ubuntu-latest + timeout-minutes: 20 + needs: [detect-changes] + # detect-changes is intentionally skipped for workflow_dispatch. Without a + # status-check function, GitHub adds an implicit success() on detect-changes + # and skips lint (and everything that needs it, including instant-release). + # always() && !cancelled() lets the OR conditions decide instead. + if: | + always() && !cancelled() && ( + github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.any-code-changed == 'true' + ) + steps: + - uses: actions/checkout@v6 + + - name: Detect C# layout + id: csharp_layout + shell: bash + run: | + set -euo pipefail + if compgen -G "*.csproj" > /dev/null || compgen -G "*.sln" > /dev/null || find src -maxdepth 5 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then + CSHARP_ROOT="." + MULTI_LANGUAGE="false" + elif find csharp -maxdepth 6 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then + CSHARP_ROOT="csharp" + MULTI_LANGUAGE="true" + else + echo "::error::Could not find a C# project at the repository root or under csharp/" + exit 1 + fi + echo "root=$CSHARP_ROOT" >> "$GITHUB_OUTPUT" + echo "multi-language=$MULTI_LANGUAGE" >> "$GITHUB_OUTPUT" + echo "Detected C# layout: root=$CSHARP_ROOT, multi-language=$MULTI_LANGUAGE" + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '8.0.x' + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Restore dependencies + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: dotnet restore + + - name: Check formatting + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: dotnet format --verify-no-changes --verbosity diagnostic + + - name: Build with warnings as errors + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: dotnet build --no-restore --configuration Release /warnaserror + + - name: Run script tests + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: bun test scripts/*.test.mjs + + - name: Check file size limit + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: bun run scripts/check-file-size.mjs + + # === TEST ON MULTIPLE OS === + test: + name: Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + needs: [detect-changes] + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + # Run tests only for events or file changes that affect runtime behavior. + if: | + always() && !cancelled() && ( + github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.any-code-changed == 'true' + ) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v6 + + - name: Detect C# layout + id: csharp_layout + shell: bash + run: | + set -euo pipefail + if compgen -G "*.csproj" > /dev/null || compgen -G "*.sln" > /dev/null || find src -maxdepth 5 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then + CSHARP_ROOT="." + MULTI_LANGUAGE="false" + elif find csharp -maxdepth 6 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then + CSHARP_ROOT="csharp" + MULTI_LANGUAGE="true" + else + echo "::error::Could not find a C# project at the repository root or under csharp/" + exit 1 + fi + echo "root=$CSHARP_ROOT" >> "$GITHUB_OUTPUT" + echo "multi-language=$MULTI_LANGUAGE" >> "$GITHUB_OUTPUT" + echo "Detected C# layout: root=$CSHARP_ROOT, multi-language=$MULTI_LANGUAGE" + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '8.0.x' + + - name: Restore dependencies + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: dotnet restore + + - name: Build + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: dotnet build --no-restore --configuration Release + + - name: Run tests + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: dotnet test --no-build --configuration Release --verbosity normal --collect:"XPlat Code Coverage" + + - name: Skip Codecov upload when token is unavailable + if: matrix.os == 'ubuntu-latest' && env.CODECOV_TOKEN == '' + run: echo "::notice::CODECOV_TOKEN is not configured; skipping Codecov upload." + + - name: Upload coverage to Codecov + if: matrix.os == 'ubuntu-latest' && env.CODECOV_TOKEN != '' + uses: codecov/codecov-action@v7 + with: + token: ${{ env.CODECOV_TOKEN }} + fail_ci_if_error: true + + # === BUILD PACKAGE === + # Only runs if lint and test pass + build: + name: Build Package + runs-on: ubuntu-latest + timeout-minutes: 20 + needs: [lint, test] + if: always() && needs.lint.result == 'success' && needs.test.result == 'success' + steps: + - uses: actions/checkout@v6 + + - name: Detect C# layout + id: csharp_layout + shell: bash + run: | + set -euo pipefail + if compgen -G "*.csproj" > /dev/null || compgen -G "*.sln" > /dev/null || find src -maxdepth 5 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then + CSHARP_ROOT="." + MULTI_LANGUAGE="false" + elif find csharp -maxdepth 6 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then + CSHARP_ROOT="csharp" + MULTI_LANGUAGE="true" + else + echo "::error::Could not find a C# project at the repository root or under csharp/" + exit 1 + fi + echo "root=$CSHARP_ROOT" >> "$GITHUB_OUTPUT" + echo "multi-language=$MULTI_LANGUAGE" >> "$GITHUB_OUTPUT" + echo "Detected C# layout: root=$CSHARP_ROOT, multi-language=$MULTI_LANGUAGE" + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '8.0.x' + + - name: Restore dependencies + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: dotnet restore + + - name: Build Release + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: dotnet build --no-restore --configuration Release + + - name: Pack NuGet package + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: dotnet pack --no-build --configuration Release --output ./artifacts + + - name: Upload artifacts + uses: actions/upload-artifact@v7 + with: + name: nuget-package + path: ${{ steps.csharp_layout.outputs.root }}/artifacts/*.nupkg + + # === AUTOMATIC RELEASE === + # Runs on push to main using changesets + release: + name: Release + needs: [lint, test, build] + if: always() && github.ref == 'refs/heads/main' && github.event_name == 'push' && needs.lint.result == 'success' && needs.test.result == 'success' && needs.build.result == 'success' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: write + packages: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Detect C# layout + id: csharp_layout + shell: bash + run: | + set -euo pipefail + if compgen -G "*.csproj" > /dev/null || compgen -G "*.sln" > /dev/null || find src -maxdepth 5 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then + CSHARP_ROOT="." + MULTI_LANGUAGE="false" + elif find csharp -maxdepth 6 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then + CSHARP_ROOT="csharp" + MULTI_LANGUAGE="true" + else + echo "::error::Could not find a C# project at the repository root or under csharp/" + exit 1 + fi + echo "root=$CSHARP_ROOT" >> "$GITHUB_OUTPUT" + echo "multi-language=$MULTI_LANGUAGE" >> "$GITHUB_OUTPUT" + echo "Detected C# layout: root=$CSHARP_ROOT, multi-language=$MULTI_LANGUAGE" + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '8.0.x' + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Check for changesets + id: check_changesets + run: | + # Count changeset files (excluding README.md and config.json) + CHANGESET_DIR="${{ steps.csharp_layout.outputs.root }}/.changeset" + CHANGESET_COUNT=$(find "$CHANGESET_DIR" -name "*.md" ! -name "README.md" 2>/dev/null | wc -l) + echo "Found $CHANGESET_COUNT changeset file(s)" + echo "has_changesets=$([[ $CHANGESET_COUNT -gt 0 ]] && echo 'true' || echo 'false')" >> "$GITHUB_OUTPUT" + echo "changeset_count=$CHANGESET_COUNT" >> "$GITHUB_OUTPUT" + + - name: Check if release is needed + # Self-healing gate: even when a changeset is absent, resume publishing + # if the csproj is missing on NuGet or its GitHub release does + # not exist. See issue #11 and the JS template's check-release-needed.mjs + # for the same pattern. + id: check_release + env: + HAS_CHANGESETS: ${{ steps.check_changesets.outputs.has_changesets }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + CSHARP_ROOT: ${{ steps.csharp_layout.outputs.root }} + run: | + bun run "${{ steps.csharp_layout.outputs.root }}/scripts/check-release-needed.mjs" \ + --csharp-root "${{ steps.csharp_layout.outputs.root }}" + + - name: Merge multiple changesets + if: steps.check_changesets.outputs.has_changesets == 'true' && steps.check_changesets.outputs.changeset_count > 1 + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: | + echo "Multiple changesets detected, merging..." + bun run scripts/merge-changesets.mjs + + - name: Version and commit + if: steps.check_changesets.outputs.has_changesets == 'true' + id: version + run: | + bun run "${{ steps.csharp_layout.outputs.root }}/scripts/version-and-commit.mjs" \ + --mode changeset \ + --csharp-root "${{ steps.csharp_layout.outputs.root }}" + + - name: Resolve release version + # Picks the version that downstream steps should publish. Prefers the + # one just committed; falls back to the csproj reported by + # check-release-needed for self-healing re-runs. + id: release_version + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' || + (steps.check_release.outputs.should_release == 'true' && + steps.check_release.outputs.skip_bump == 'true') + run: | + if [ -n "${{ steps.version.outputs.new_version }}" ]; then + VERSION="${{ steps.version.outputs.new_version }}" + else + VERSION="${{ steps.check_release.outputs.current_version }}" + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Releasing version: $VERSION" + + - name: Build release package + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' || + (steps.check_release.outputs.should_release == 'true' && + steps.check_release.outputs.skip_bump == 'true') + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: | + dotnet restore + dotnet build --configuration Release + dotnet pack --no-build --configuration Release --output ./artifacts + + - name: Resolve NuGet package id + id: package + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' || + (steps.check_release.outputs.should_release == 'true' && + steps.check_release.outputs.skip_bump == 'true') + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: | + PACKAGE_ID=$(dotnet msbuild src/MyPackage/MyPackage.csproj -getProperty:PackageId | tail -n 1 | tr -d '\r') + if [ -z "$PACKAGE_ID" ]; then + PACKAGE_ID=$(dotnet msbuild src/MyPackage/MyPackage.csproj -getProperty:AssemblyName | tail -n 1 | tr -d '\r') + fi + if [ -z "$PACKAGE_ID" ]; then + PACKAGE_ID="MyPackage" + fi + echo "id=$PACKAGE_ID" >> "$GITHUB_OUTPUT" + + - name: Validate NuGet API key + # Upfront validation surfaces an expired/invalid NUGET_API_KEY before + # we attempt a push that would otherwise return HTTP 403 mid-flight. + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' || + (steps.check_release.outputs.should_release == 'true' && + steps.check_release.outputs.skip_bump == 'true') + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + if [ -z "$NUGET_API_KEY" ]; then + echo "::warning::NUGET_API_KEY is not configured — NuGet publish will be skipped." + exit 0 + fi + echo "NUGET_API_KEY length: ${#NUGET_API_KEY}" + + - name: Publish to NuGet + id: nuget_publish + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' || + (steps.check_release.outputs.should_release == 'true' && + steps.check_release.outputs.skip_bump == 'true') + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: | + if [ -n "$NUGET_API_KEY" ]; then + dotnet nuget push ./artifacts/*.nupkg --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate + echo "published=true" >> "$GITHUB_OUTPUT" + else + echo "NUGET_API_KEY not set, skipping NuGet publish" + echo "published=false" >> "$GITHUB_OUTPUT" + fi + + - name: Wait for NuGet indexing + # NuGet's flat-container API can take up to 15 minutes to reflect a + # newly pushed package (see issue #13). Poll it via the tested helper + # before creating the GitHub release so users can `dotnet add package` + # the version mentioned in the release notes. + if: >- + steps.nuget_publish.outputs.published == 'true' && ( + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' || + (steps.check_release.outputs.should_release == 'true' && + steps.check_release.outputs.skip_bump == 'true')) + run: | + bun run "${{ steps.csharp_layout.outputs.root }}/scripts/wait-for-nuget.mjs" \ + --package-id "${{ steps.package.outputs.id }}" \ + --release-version "${{ steps.release_version.outputs.version }}" + + - name: Smoke-test published NuGet package + # Indexing proves the version exists; this installs it in a clean + # throwaway project and runs the advertised library entry point before + # release notes are published. The helper captures command output + # before previewing it, avoiding SIGPIPE-prone live-output pagination. + if: >- + steps.nuget_publish.outputs.published == 'true' && ( + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' || + (steps.check_release.outputs.should_release == 'true' && + steps.check_release.outputs.skip_bump == 'true')) + run: | + bun run scripts/smoke-test-nuget-package.mjs \ + --package-id "${{ steps.package.outputs.id }}" \ + --release-version "${{ steps.release_version.outputs.version }}" + + - name: Create GitHub Release + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' || + (steps.check_release.outputs.should_release == 'true' && + steps.check_release.outputs.skip_bump == 'true') + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + bun run "${{ steps.csharp_layout.outputs.root }}/scripts/create-github-release.mjs" \ + --release-version "${{ steps.release_version.outputs.version }}" \ + --repository "${{ github.repository }}" \ + --csharp-root "${{ steps.csharp_layout.outputs.root }}" \ + --language "C#" \ + --package-id "${{ steps.package.outputs.id }}" \ + --assets-glob "./artifacts/*.nupkg" + + # === MANUAL INSTANT RELEASE === + # Triggered via workflow_dispatch with instant mode + instant-release: + name: Instant Release + needs: [lint, test, build] + # Mirror the automatic release job: a status-check function plus explicit + # needs.*.result checks so the dispatch run is evaluated even though + # detect-changes (an upstream skip) propagated through the needs graph. + if: | + always() && !cancelled() && + github.event_name == 'workflow_dispatch' && + github.event.inputs.release_mode == 'instant' && + needs.lint.result == 'success' && + needs.test.result == 'success' && + needs.build.result == 'success' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: write + packages: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Detect C# layout + id: csharp_layout + shell: bash + run: | + set -euo pipefail + if compgen -G "*.csproj" > /dev/null || compgen -G "*.sln" > /dev/null || find src -maxdepth 5 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then + CSHARP_ROOT="." + MULTI_LANGUAGE="false" + elif find csharp -maxdepth 6 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then + CSHARP_ROOT="csharp" + MULTI_LANGUAGE="true" + else + echo "::error::Could not find a C# project at the repository root or under csharp/" + exit 1 + fi + echo "root=$CSHARP_ROOT" >> "$GITHUB_OUTPUT" + echo "multi-language=$MULTI_LANGUAGE" >> "$GITHUB_OUTPUT" + echo "Detected C# layout: root=$CSHARP_ROOT, multi-language=$MULTI_LANGUAGE" + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '8.0.x' + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Version and commit + id: version + run: | + bun run "${{ steps.csharp_layout.outputs.root }}/scripts/version-and-commit.mjs" \ + --mode instant \ + --bump-type "${{ github.event.inputs.bump_type }}" \ + --description "${{ github.event.inputs.description }}" \ + --csharp-root "${{ steps.csharp_layout.outputs.root }}" + + - name: Build package + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: | + dotnet restore + dotnet build --configuration Release + dotnet pack --no-build --configuration Release --output ./artifacts + + - name: Resolve NuGet package id + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' + id: package + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: | + PACKAGE_ID=$(dotnet msbuild src/MyPackage/MyPackage.csproj -getProperty:PackageId | tail -n 1 | tr -d '\r') + if [ -z "$PACKAGE_ID" ]; then + PACKAGE_ID=$(dotnet msbuild src/MyPackage/MyPackage.csproj -getProperty:AssemblyName | tail -n 1 | tr -d '\r') + fi + if [ -z "$PACKAGE_ID" ]; then + PACKAGE_ID="MyPackage" + fi + echo "id=$PACKAGE_ID" >> "$GITHUB_OUTPUT" + + - name: Validate NuGet API key + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + if [ -z "$NUGET_API_KEY" ]; then + echo "::warning::NUGET_API_KEY is not configured — NuGet publish will be skipped." + exit 0 + fi + echo "NUGET_API_KEY length: ${#NUGET_API_KEY}" + + - name: Publish to NuGet + id: nuget_publish + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: | + if [ -n "$NUGET_API_KEY" ]; then + dotnet nuget push ./artifacts/*.nupkg --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate + echo "published=true" >> "$GITHUB_OUTPUT" + else + echo "NUGET_API_KEY not set, skipping NuGet publish" + echo "published=false" >> "$GITHUB_OUTPUT" + fi + + - name: Wait for NuGet indexing + # NuGet's flat-container API can take up to 15 minutes to reflect a + # newly pushed package (see issue #13). Poll it via the tested helper + # before creating the GitHub release so users can `dotnet add package` + # the version mentioned in the release notes. + if: >- + steps.nuget_publish.outputs.published == 'true' && ( + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true') + run: | + bun run "${{ steps.csharp_layout.outputs.root }}/scripts/wait-for-nuget.mjs" \ + --package-id "${{ steps.package.outputs.id }}" \ + --release-version "${{ steps.version.outputs.new_version }}" + + - name: Smoke-test published NuGet package + # Indexing proves the version exists; this installs it in a clean + # throwaway project and runs the advertised library entry point before + # release notes are published. The helper captures command output + # before previewing it, avoiding SIGPIPE-prone live-output pagination. + if: >- + steps.nuget_publish.outputs.published == 'true' && ( + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true') + run: | + bun run scripts/smoke-test-nuget-package.mjs \ + --package-id "${{ steps.package.outputs.id }}" \ + --release-version "${{ steps.version.outputs.new_version }}" + + - name: Create GitHub Release + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + bun run "${{ steps.csharp_layout.outputs.root }}/scripts/create-github-release.mjs" \ + --release-version "${{ steps.version.outputs.new_version }}" \ + --repository "${{ github.repository }}" \ + --csharp-root "${{ steps.csharp_layout.outputs.root }}" \ + --language "C#" \ + --package-id "${{ steps.package.outputs.id }}" \ + --assets-glob "./artifacts/*.nupkg" + + # === MANUAL CHANGESET PR === + # Creates a pull request with the changeset for review + changeset-pr: + name: Create Changeset PR + if: github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'changeset-pr' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Detect C# layout + id: csharp_layout + shell: bash + run: | + set -euo pipefail + if compgen -G "*.csproj" > /dev/null || compgen -G "*.sln" > /dev/null || find src -maxdepth 5 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then + CSHARP_ROOT="." + MULTI_LANGUAGE="false" + elif find csharp -maxdepth 6 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then + CSHARP_ROOT="csharp" + MULTI_LANGUAGE="true" + else + echo "::error::Could not find a C# project at the repository root or under csharp/" + exit 1 + fi + echo "root=$CSHARP_ROOT" >> "$GITHUB_OUTPUT" + echo "multi-language=$MULTI_LANGUAGE" >> "$GITHUB_OUTPUT" + echo "Detected C# layout: root=$CSHARP_ROOT, multi-language=$MULTI_LANGUAGE" + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Create changeset file + run: | + CHANGESET_ID=$(date +%s) + CHANGESET_DIR="${{ steps.csharp_layout.outputs.root }}/.changeset" + mkdir -p "$CHANGESET_DIR" + CHANGESET_FILE="$CHANGESET_DIR/manual-release-${CHANGESET_ID}.md" + + cat > "$CHANGESET_FILE" << 'EOF' + --- + 'MyPackage': ${{ github.event.inputs.bump_type }} + --- + + ${{ github.event.inputs.description || 'Manual release' }} + EOF + + echo "Created changeset: $CHANGESET_FILE" + + - name: Create Pull Request + uses: peter-evans/create-pull-request@v8 + with: + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: 'chore: add changeset for manual ${{ github.event.inputs.bump_type }} release' + branch: changeset-manual-release-${{ github.run_id }} + delete-branch: true + title: 'chore: manual ${{ github.event.inputs.bump_type }} release' + body: | + ## Manual Release Request + + This PR was created by a manual workflow trigger to prepare a **${{ github.event.inputs.bump_type }}** release. + + ### Release Details + - **Type:** ${{ github.event.inputs.bump_type }} + - **Description:** ${{ github.event.inputs.description || 'Manual release' }} + - **Triggered by:** @${{ github.actor }} + + ### Next Steps + 1. Review the changeset in this PR + 2. Merge this PR to main + 3. The automated release workflow will version, publish, and create a GitHub release diff --git a/dev/log/issues/298/pulls/299/templates/csharp/security.yml b/dev/log/issues/298/pulls/299/templates/csharp/security.yml new file mode 100644 index 00000000..80b2b935 --- /dev/null +++ b/dev/log/issues/298/pulls/299/templates/csharp/security.yml @@ -0,0 +1,49 @@ +name: Security + +on: + push: + branches: [main] + pull_request: + schedule: + - cron: '0 6 * * 1' + +permissions: + contents: read + +jobs: + codeql: + name: CodeQL (${{ matrix.language }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-codeql-${{ matrix.language }} + cancel-in-progress: true + permissions: + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + language: [csharp, actions] + steps: + - uses: actions/checkout@v6 + - uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + - uses: github/codeql-action/autobuild@v4 + - uses: github/codeql-action/analyze@v4 + + dependency-review: + name: Dependency Review + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v6 + - uses: actions/dependency-review-action@v5 + with: + fail-on-severity: high + comment-summary-in-pr: on-failure diff --git a/dev/log/issues/298/pulls/299/templates/csharp/workflows.yml b/dev/log/issues/298/pulls/299/templates/csharp/workflows.yml new file mode 100644 index 00000000..a81031e6 --- /dev/null +++ b/dev/log/issues/298/pulls/299/templates/csharp/workflows.yml @@ -0,0 +1,44 @@ +name: Workflows + +on: + push: + branches: + - main + paths: + - '.github/workflows/**' + - '.github/actions/**' + pull_request: + types: [opened, synchronize, reopened] + paths: + - '.github/workflows/**' + - '.github/actions/**' + workflow_dispatch: + +# Least-privilege default; this workflow only ever reads the tree. +permissions: + contents: read + +jobs: + actionlint: + name: Lint Workflows + runs-on: ubuntu-latest + # Typical run: <1min. 10min prevents an image pull stall from hanging. + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-actionlint + cancel-in-progress: true + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + # The Docker image bundles shellcheck and pyflakes, so this also lints + # every `run:` block - that is what catches script injection sinks such as + # `${{ github.head_ref }}` pasted into a shell script (issue #49). + # A *native* actionlint binary without shellcheck on PATH silently skips + # the shell checks and exits 0, so keep using the Docker image here. + - uses: docker://rhysd/actionlint:1.7.7 + with: + args: -color diff --git a/dev/log/issues/298/pulls/299/templates/go-workflows.txt b/dev/log/issues/298/pulls/299/templates/go-workflows.txt new file mode 100644 index 00000000..0b255060 --- /dev/null +++ b/dev/log/issues/298/pulls/299/templates/go-workflows.txt @@ -0,0 +1 @@ +release.yml diff --git a/dev/log/issues/298/pulls/299/templates/go/release.yml b/dev/log/issues/298/pulls/299/templates/go/release.yml new file mode 100644 index 00000000..b84b0588 --- /dev/null +++ b/dev/log/issues/298/pulls/299/templates/go/release.yml @@ -0,0 +1,339 @@ +name: CI/CD Pipeline + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened] + workflow_dispatch: + inputs: + release_mode: + description: 'Release mode' + required: true + type: choice + default: 'instant' + options: + - instant + - changeset + bump_type: + description: 'Version bump type (for instant mode)' + required: true + type: choice + options: + - patch + - minor + - major + description: + description: 'Release description (optional)' + required: false + type: string + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: write + pull-requests: write + +jobs: + # === DETECT CHANGES - determines which jobs should run === + detect-changes: + name: Detect Changes + runs-on: ubuntu-latest + if: github.event_name != 'workflow_dispatch' + outputs: + go-changed: ${{ steps.changes.outputs.go-changed }} + mod-changed: ${{ steps.changes.outputs.mod-changed }} + mjs-changed: ${{ steps.changes.outputs.mjs-changed }} + docs-changed: ${{ steps.changes.outputs.docs-changed }} + workflow-changed: ${{ steps.changes.outputs.workflow-changed }} + any-code-changed: ${{ steps.changes.outputs.any-code-changed }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Detect changes + id: changes + env: + GITHUB_EVENT_NAME: ${{ github.event_name }} + GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }} + GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: bun scripts/detect-code-changes.mjs + + # === CHANGESET CHECK - only runs on PRs with code changes === + # Docs-only PRs (./docs folder, markdown files) don't require changesets + changeset-check: + name: Check for Changeset + runs-on: ubuntu-latest + needs: [detect-changes] + if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any-code-changed == 'true' + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Check for changeset + env: + GITHUB_BASE_REF: ${{ github.base_ref }} + GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }} + GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: bun scripts/validate-changeset.mjs + + # === LINT AND FORMAT CHECK === + # Lint runs independently of changeset-check - it's a fast check that should always run + # See: https://github.com/link-assistant/hive-mind/pull/1024 for why this dependency was removed + lint: + name: Lint + runs-on: ubuntu-latest + needs: [detect-changes] + if: | + github.event_name == 'push' || + github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.go-changed == 'true' || + needs.detect-changes.outputs.mod-changed == 'true' || + needs.detect-changes.outputs.mjs-changed == 'true' || + needs.detect-changes.outputs.docs-changed == 'true' || + needs.detect-changes.outputs.workflow-changed == 'true' + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + cache: false + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Check formatting + run: | + if [ -n "$(gofmt -l .)" ]; then + echo "The following files are not formatted correctly:" + gofmt -l . + exit 1 + fi + + - name: Run go vet + run: go vet ./... + + - name: Install staticcheck + run: go install honnef.co/go/tools/cmd/staticcheck@latest + + - name: Run staticcheck + run: staticcheck ./... + + - name: Check file sizes + run: bun scripts/check-file-size.mjs + + # === TEST === + test: + name: Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + needs: [detect-changes, changeset-check] + # Run if: push event, workflow_dispatch, OR changeset-check succeeded, OR changeset-check was skipped (docs-only PR) + if: always() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || needs.changeset-check.result == 'success' || needs.changeset-check.result == 'skipped') + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + cache: false + + - name: Run tests (Unix) + if: matrix.os != 'windows-latest' + run: go test -v -race -coverprofile=coverage.out ./... + + - name: Run tests (Windows) + if: matrix.os == 'windows-latest' + run: go test -v -race ./... + + - name: Upload coverage (Ubuntu only) + if: matrix.os == 'ubuntu-latest' + uses: codecov/codecov-action@v4 + with: + files: ./coverage.out + fail_ci_if_error: false + continue-on-error: true + + # === BUILD === + build: + name: Build + runs-on: ubuntu-latest + needs: [lint, test] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + cache: false + + - name: Build package + run: go build ./... + + - name: Build example + run: go build -o example ./examples/basic_usage.go + + # === AUTO RELEASE - runs on main when PRs with changesets are merged === + auto-release: + name: Auto Release + runs-on: ubuntu-latest + needs: [lint, test, build] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Check for changesets + id: check_changesets + run: | + # Count changeset files (excluding README.md) + CHANGESET_COUNT=$(find .changeset -name "*.md" ! -name "README.md" 2>/dev/null | wc -l) + echo "Found $CHANGESET_COUNT changeset file(s)" + echo "has_changesets=$([[ $CHANGESET_COUNT -gt 0 ]] && echo 'true' || echo 'false')" >> $GITHUB_OUTPUT + echo "changeset_count=$CHANGESET_COUNT" >> $GITHUB_OUTPUT + + - name: Merge multiple changesets + if: steps.check_changesets.outputs.has_changesets == 'true' && steps.check_changesets.outputs.changeset_count > 1 + run: | + echo "Multiple changesets detected, merging..." + bun scripts/merge-changesets.mjs + + - name: Configure git + if: steps.check_changesets.outputs.has_changesets == 'true' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Version and release + if: steps.check_changesets.outputs.has_changesets == 'true' + id: version + run: bun scripts/version-and-commit.mjs --mode changeset + env: + CI: true + + - name: Create GitHub Release + if: steps.check_changesets.outputs.has_changesets == 'true' && steps.version.outputs.version_committed == 'true' + run: bun scripts/create-github-release.mjs --version ${{ steps.version.outputs.new_version }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # === MANUAL INSTANT RELEASE === + instant-release: + name: Instant Release + runs-on: ubuntu-latest + needs: [lint, test, build] + if: github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'instant' + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Version and release + id: version + run: bun scripts/version-and-commit.mjs --mode instant --bump-type ${{ github.event.inputs.bump_type }} --description "${{ github.event.inputs.description }}" + env: + CI: true + + - name: Create GitHub Release + if: steps.version.outputs.version_committed == 'true' + run: bun scripts/create-github-release.mjs --version ${{ steps.version.outputs.new_version }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # === MANUAL CHANGESET RELEASE - processes existing changesets === + changeset-release: + name: Changeset Release + runs-on: ubuntu-latest + needs: [lint, test, build] + if: github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'changeset' + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Check for changesets + id: check_changesets + run: | + CHANGESET_COUNT=$(find .changeset -name "*.md" ! -name "README.md" 2>/dev/null | wc -l) + echo "Found $CHANGESET_COUNT changeset file(s)" + if [ "$CHANGESET_COUNT" -eq 0 ]; then + echo "No changesets found to release" + exit 1 + fi + echo "changeset_count=$CHANGESET_COUNT" >> $GITHUB_OUTPUT + + - name: Merge multiple changesets + if: steps.check_changesets.outputs.changeset_count > 1 + run: bun scripts/merge-changesets.mjs + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Version and release + id: version + run: bun scripts/version-and-commit.mjs --mode changeset + env: + CI: true + + - name: Create GitHub Release + if: steps.version.outputs.version_committed == 'true' + run: bun scripts/create-github-release.mjs --version ${{ steps.version.outputs.new_version }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/dev/log/issues/298/pulls/299/templates/java-workflows.txt b/dev/log/issues/298/pulls/299/templates/java-workflows.txt new file mode 100644 index 00000000..0b255060 --- /dev/null +++ b/dev/log/issues/298/pulls/299/templates/java-workflows.txt @@ -0,0 +1 @@ +release.yml diff --git a/dev/log/issues/298/pulls/299/templates/java/release.yml b/dev/log/issues/298/pulls/299/templates/java/release.yml new file mode 100644 index 00000000..8c593a4f --- /dev/null +++ b/dev/log/issues/298/pulls/299/templates/java/release.yml @@ -0,0 +1,457 @@ +name: CI/CD Pipeline + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened] + workflow_dispatch: + inputs: + release_mode: + description: 'Release mode' + required: true + default: 'changeset' + type: choice + options: + - changeset + - instant + - changeset-pr + bump_type: + description: 'Version bump type (for instant mode or changeset-pr)' + required: false + default: 'patch' + type: choice + options: + - patch + - minor + - major + description: + description: 'Change description (for changeset-pr mode)' + required: false + type: string + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + JAVA_VERSION: '21' + JAVA_DISTRIBUTION: 'temurin' + +jobs: + # ============================================================================= + # Detect Changes - determines which jobs should run + # ============================================================================= + detect-changes: + name: Detect Changes + runs-on: ubuntu-latest + if: github.event_name != 'workflow_dispatch' + outputs: + java-changed: ${{ steps.changes.outputs.java-changed }} + pom-changed: ${{ steps.changes.outputs.pom-changed }} + mjs-changed: ${{ steps.changes.outputs.mjs-changed }} + docs-changed: ${{ steps.changes.outputs.docs-changed }} + workflow-changed: ${{ steps.changes.outputs.workflow-changed }} + any-code-changed: ${{ steps.changes.outputs.any-code-changed }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Detect changes + id: changes + env: + GITHUB_EVENT_NAME: ${{ github.event_name }} + GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }} + GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: bun scripts/detect-code-changes.mjs + + # ============================================================================= + # Lint and Format Check + # Runs independently of changeset-check - it's a fast check that should always run + # See: https://github.com/link-foundation/js-ai-driven-development-pipeline-template/pull/18 + # ============================================================================= + lint: + name: Lint & Format + runs-on: ubuntu-latest + needs: [detect-changes] + if: | + github.event_name == 'workflow_dispatch' || + github.event_name == 'push' || + needs.detect-changes.outputs.java-changed == 'true' || + needs.detect-changes.outputs.pom-changed == 'true' || + needs.detect-changes.outputs.mjs-changed == 'true' || + needs.detect-changes.outputs.docs-changed == 'true' || + needs.detect-changes.outputs.workflow-changed == 'true' + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK ${{ env.JAVA_VERSION }} + uses: actions/setup-java@v4 + with: + java-version: ${{ env.JAVA_VERSION }} + distribution: ${{ env.JAVA_DISTRIBUTION }} + cache: maven + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Check code formatting (Spotless) + run: mvn spotless:check -B + + - name: Run SpotBugs static analysis + run: mvn spotbugs:check -B + + - name: Check file sizes + run: bun scripts/check-file-size.mjs + + # ============================================================================= + # Build and Test + # ============================================================================= + test: + name: Test (Java ${{ matrix.java }}, ${{ matrix.os }}) + runs-on: ${{ matrix.os }} + needs: [detect-changes, changeset-check] + # Run if: push event, workflow_dispatch, OR changeset-check succeeded, OR changeset-check was skipped (docs-only PR) + if: always() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || needs.changeset-check.result == 'success' || needs.changeset-check.result == 'skipped') + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + java: ['21'] + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK ${{ matrix.java }} + uses: actions/setup-java@v4 + with: + java-version: ${{ matrix.java }} + distribution: ${{ env.JAVA_DISTRIBUTION }} + cache: maven + + - name: Run tests with coverage + run: mvn test jacoco:report -B + + - name: Upload coverage to Codecov + if: matrix.os == 'ubuntu-latest' && matrix.java == '21' + uses: codecov/codecov-action@v4 + with: + file: target/site/jacoco/jacoco.xml + flags: unittests + fail_ci_if_error: false + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + # ============================================================================= + # Build Package + # ============================================================================= + build: + name: Build Package + runs-on: ubuntu-latest + needs: [lint, test] + # Run only if lint and test succeeded (handles skipped lint gracefully) + if: always() && (needs.lint.result == 'success' || needs.lint.result == 'skipped') && needs.test.result == 'success' + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK ${{ env.JAVA_VERSION }} + uses: actions/setup-java@v4 + with: + java-version: ${{ env.JAVA_VERSION }} + distribution: ${{ env.JAVA_DISTRIBUTION }} + cache: maven + + - name: Build with Maven + run: mvn package -DskipTests -B + + - name: Verify JAR contents + run: | + echo "=== JAR contents ===" + jar tf target/*.jar | head -50 + echo "..." + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: build-artifacts + path: | + target/*.jar + target/site/jacoco/ + retention-days: 7 + + # ============================================================================= + # Changeset Validation (PRs only, skipped for docs-only changes) + # Docs-only PRs (./docs folder, markdown files) don't require changesets + # ============================================================================= + changeset-check: + name: Changeset Check + runs-on: ubuntu-latest + needs: [detect-changes] + if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any-code-changed == 'true' + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Validate changeset + run: bun scripts/validate-changeset.mjs + env: + GITHUB_BASE_REF: ${{ github.base_ref }} + + # ============================================================================= + # Auto Release (on push to main with changesets) + # ============================================================================= + auto-release: + name: Auto Release + runs-on: ubuntu-latest + needs: [build] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + permissions: + contents: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up JDK ${{ env.JAVA_VERSION }} + uses: actions/setup-java@v4 + with: + java-version: ${{ env.JAVA_VERSION }} + distribution: ${{ env.JAVA_DISTRIBUTION }} + cache: maven + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Check for changesets + id: changesets + run: | + CHANGESET_COUNT=$(find .changeset -name "*.md" ! -name "README.md" 2>/dev/null | wc -l) + echo "count=$CHANGESET_COUNT" >> $GITHUB_OUTPUT + if [ "$CHANGESET_COUNT" -gt 0 ]; then + echo "has_changesets=true" >> $GITHUB_OUTPUT + echo "Found $CHANGESET_COUNT changeset(s)" + else + echo "has_changesets=false" >> $GITHUB_OUTPUT + echo "No changesets found" + fi + + - name: Merge changesets (if multiple) + if: steps.changesets.outputs.has_changesets == 'true' && steps.changesets.outputs.count > 1 + run: bun scripts/merge-changesets.mjs + + - name: Run version and commit script + if: steps.changesets.outputs.has_changesets == 'true' + id: release + run: bun scripts/version-and-commit.mjs --mode changeset + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Build release artifacts + if: steps.release.outputs.released == 'true' + run: mvn package source:jar javadoc:jar -DskipTests -B + + - name: Create GitHub Release + if: steps.release.outputs.released == 'true' + run: | + bun scripts/create-github-release.mjs \ + --release-version "${{ steps.release.outputs.new_version }}" \ + --repository "${{ github.repository }}" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload release artifacts + if: steps.release.outputs.released == 'true' + run: | + TAG="v${{ steps.release.outputs.new_version }}" + gh release upload "$TAG" target/*.jar --clobber || true + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # ============================================================================= + # Manual Release - Changeset Mode (workflow dispatch) + # ============================================================================= + manual-release-changeset: + name: Manual Release (Changeset) + runs-on: ubuntu-latest + needs: [build] + if: github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'changeset' + permissions: + contents: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up JDK ${{ env.JAVA_VERSION }} + uses: actions/setup-java@v4 + with: + java-version: ${{ env.JAVA_VERSION }} + distribution: ${{ env.JAVA_DISTRIBUTION }} + cache: maven + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Merge changesets (if multiple) + run: bun scripts/merge-changesets.mjs + + - name: Run version and commit script + id: release + run: bun scripts/version-and-commit.mjs --mode changeset + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Build release artifacts + if: steps.release.outputs.released == 'true' + run: mvn package source:jar javadoc:jar -DskipTests -B + + - name: Create GitHub Release + if: steps.release.outputs.released == 'true' + run: | + bun scripts/create-github-release.mjs \ + --release-version "${{ steps.release.outputs.new_version }}" \ + --repository "${{ github.repository }}" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload release artifacts + if: steps.release.outputs.released == 'true' + run: | + TAG="v${{ steps.release.outputs.new_version }}" + gh release upload "$TAG" target/*.jar --clobber || true + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # ============================================================================= + # Manual Release - Instant Mode (workflow dispatch) + # ============================================================================= + manual-release-instant: + name: Manual Release (Instant) + runs-on: ubuntu-latest + needs: [build] + if: github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'instant' + permissions: + contents: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up JDK ${{ env.JAVA_VERSION }} + uses: actions/setup-java@v4 + with: + java-version: ${{ env.JAVA_VERSION }} + distribution: ${{ env.JAVA_DISTRIBUTION }} + cache: maven + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Run version and commit script + id: release + run: bun scripts/version-and-commit.mjs --mode instant --bump-type ${{ inputs.bump_type }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Build release artifacts + if: steps.release.outputs.released == 'true' + run: mvn package source:jar javadoc:jar -DskipTests -B + + - name: Create GitHub Release + if: steps.release.outputs.released == 'true' + run: | + bun scripts/create-github-release.mjs \ + --release-version "${{ steps.release.outputs.new_version }}" \ + --repository "${{ github.repository }}" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload release artifacts + if: steps.release.outputs.released == 'true' + run: | + TAG="v${{ steps.release.outputs.new_version }}" + gh release upload "$TAG" target/*.jar --clobber || true + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # ============================================================================= + # Manual Changeset PR (workflow dispatch) + # ============================================================================= + changeset-pr: + name: Create Changeset PR + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'changeset-pr' + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Create changeset file + run: | + DESCRIPTION="${{ inputs.description }}" + if [ -z "$DESCRIPTION" ]; then + DESCRIPTION="Manual release" + fi + bun scripts/create-manual-changeset.mjs \ + --bump-type "${{ inputs.bump_type }}" \ + --description "$DESCRIPTION" + + - name: Create Pull Request + uses: peter-evans/create-pull-request@v6 + with: + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: "chore: add changeset for ${{ inputs.bump_type }} release" + branch: changeset-manual-release-${{ github.run_id }} + delete-branch: true + title: "chore: ${{ inputs.bump_type }} release changeset" + body: | + ## Summary + + This PR adds a changeset for a **${{ inputs.bump_type }}** release. + + ${{ inputs.description }} + + --- + *This PR was automatically created by the changeset-pr workflow.* + labels: | + release + automated diff --git a/dev/log/issues/298/pulls/299/templates/js-workflows.txt b/dev/log/issues/298/pulls/299/templates/js-workflows.txt new file mode 100644 index 00000000..59335ffa --- /dev/null +++ b/dev/log/issues/298/pulls/299/templates/js-workflows.txt @@ -0,0 +1,5 @@ +example-app.yml +links.yml +release.yml +security.yml +workflows.yml diff --git a/dev/log/issues/298/pulls/299/templates/js/example-app.yml b/dev/log/issues/298/pulls/299/templates/js/example-app.yml new file mode 100644 index 00000000..649702a8 --- /dev/null +++ b/dev/log/issues/298/pulls/299/templates/js/example-app.yml @@ -0,0 +1,318 @@ +name: Example app + +on: + pull_request: + types: [opened, synchronize, reopened] + paths: + - 'examples/universal-app/**' + - 'src/**' + - 'package.json' + - 'package-lock.json' + - 'scripts/update-preview-images.mjs' + - '.github/workflows/example-app.yml' + push: + branches: + - main + paths: + - 'examples/universal-app/**' + - 'src/**' + - 'package.json' + - 'package-lock.json' + - 'scripts/update-preview-images.mjs' + - '.github/workflows/example-app.yml' + workflow_dispatch: + +permissions: + contents: read + +# Provide Git config to actions/checkout itself; checkout runs git init before +# any workflow step can configure Git. +env: + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: main + +jobs: + web-build: + name: Build web app + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-web-build + cancel-in-progress: true + steps: + - uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '24.x' + cache: npm + cache-dependency-path: examples/universal-app/package-lock.json + + - name: Configure GitHub Pages + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: actions/configure-pages@v6 + + - name: Install app dependencies + run: npm ci --prefix examples/universal-app --no-audit --no-fund + + - name: Build app + run: npm run example:web:build + env: + GITHUB_PAGES: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + VITE_REPOSITORY_URL: https://github.com/${{ github.repository }} + + - name: Upload web build artifact + uses: actions/upload-artifact@v7 + with: + name: universal-example-web + path: examples/universal-app/dist + if-no-files-found: error + + - name: Upload GitHub Pages artifact + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: actions/upload-pages-artifact@v5 + with: + path: examples/universal-app/dist + + # Requires Settings → Pages → Source = GitHub Actions to be set once + # in the repository before this job can succeed. See README → "Deploying + # the example app". The Pages source cannot be configured from a workflow. + pages-deploy: + name: Deploy GitHub Pages + needs: [web-build] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: main-writer-${{ github.repository }}-main + cancel-in-progress: false + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy Pages artifact + id: deployment + uses: actions/deploy-pages@v5 + + desktop-package: + name: Package desktop app (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-desktop-package-${{ matrix.os }} + cancel-in-progress: true + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '24.x' + cache: npm + cache-dependency-path: examples/universal-app/package-lock.json + + - name: Install app dependencies + run: npm ci --prefix examples/universal-app --no-audit --no-fund + + - name: Package Electron app + shell: bash + run: | + npm run example:desktop:package + for attempt in {1..30}; do + echo "Waiting for the packaged app (attempt ${attempt}/30)" + if [[ -d examples/universal-app/out ]] && + [[ -n "$(find examples/universal-app/out -mindepth 1 -print -quit)" ]]; then + find examples/universal-app/out -maxdepth 2 -mindepth 1 -print + exit 0 + fi + sleep 1 + done + echo "::error::Desktop package output was not created at examples/universal-app/out" + exit 1 + env: + VITE_REPOSITORY_URL: https://github.com/${{ github.repository }} + + - name: Upload desktop package + uses: actions/upload-artifact@v7 + with: + name: universal-example-desktop-${{ matrix.os }} + path: examples/universal-app/out + if-no-files-found: error + + android-build: + name: Build Android app + if: github.event_name == 'workflow_dispatch' && vars.EXAMPLE_APP_ENABLE_ANDROID_BUILD == 'true' + runs-on: ubuntu-latest + timeout-minutes: 30 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-android-build + cancel-in-progress: true + steps: + - uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '24.x' + cache: npm + cache-dependency-path: examples/universal-app/package-lock.json + + - name: Setup Java + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '21' + + - name: Setup Android SDK + uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3.2.2 + + - name: Install app dependencies + run: npm ci --prefix examples/universal-app --no-audit --no-fund + + - name: Add Android project + run: npm --prefix examples/universal-app run mobile:android:add + + - name: Build Android project + run: npm --prefix examples/universal-app run mobile:android:build + + - name: Upload Android output + uses: actions/upload-artifact@v7 + with: + name: universal-example-android + path: | + examples/universal-app/android/app/build/outputs/**/*.apk + examples/universal-app/android/app/build/outputs/**/*.aab + if-no-files-found: warn + + ios-build: + name: Build iOS app + if: github.event_name == 'workflow_dispatch' && vars.EXAMPLE_APP_ENABLE_IOS_BUILD == 'true' + runs-on: macos-latest + timeout-minutes: 30 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-ios-build + cancel-in-progress: true + steps: + - uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '24.x' + cache: npm + cache-dependency-path: examples/universal-app/package-lock.json + + - name: Install app dependencies + run: npm ci --prefix examples/universal-app --no-audit --no-fund + + - name: Add iOS project + run: npm --prefix examples/universal-app run mobile:ios:add + + - name: Build iOS project + run: npm --prefix examples/universal-app run mobile:ios:build + + # Regenerate example-app preview screenshots (docs/screenshots/example-app/*) + # using browser-commander + Playwright so README/site images always reflect + # the current UI. Issue: #62. Implementation: scripts/update-preview-images.mjs. + preview-regen: + name: Regenerate Preview Images + runs-on: ubuntu-latest + container: + # Keep this tag in sync with the playwright package version below. + image: mcr.microsoft.com/playwright:v1.59.1-noble + timeout-minutes: 20 + concurrency: + group: main-writer-${{ github.repository }}-main + cancel-in-progress: false + if: | + (github.event_name == 'push' && github.ref == 'refs/heads/main') || + github.event_name == 'workflow_dispatch' + permissions: + contents: write + # The push helper opens and merges a pull request when a repository + # ruleset declines the direct push to main (issue #143). + pull-requests: write + env: + PLAYWRIGHT_BROWSERS_PATH: /ms-playwright + steps: + - uses: actions/checkout@v6 + with: + # Regenerate against main HEAD so the bot commit lands on a + # fast-forward parent regardless of which trigger started the job. + ref: main + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 + + - name: Install example app dependencies + run: npm ci --prefix examples/universal-app --no-audit --no-fund + + # Deliberately installed outside the lockfile: these tools are only used + # by this preview-image job and both versions are pinned exactly. + - name: Install browser automation dependencies + env: + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1' + run: npm install --no-save --package-lock=false --no-audit --no-fund browser-commander@0.8.1 playwright@1.59.1 # zizmor: ignore[adhoc-packages] + + - name: Regenerate preview images + run: node scripts/update-preview-images.mjs + + - name: Detect drift + id: drift + run: | + if [[ -n "$(git status --porcelain)" ]]; then + echo "drift=true" >> "$GITHUB_OUTPUT" + echo "Preview images drifted:" + git status --porcelain + else + echo "drift=false" >> "$GITHUB_OUTPUT" + echo "Preview images already current." + fi + + - name: Commit drift back to main + if: steps.drift.outputs.drift == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + # Stage only generated artifacts so unrelated changes can't leak in. + git add docs/screenshots/example-app/*.png || true + if git diff --cached --quiet; then + echo "::notice::No tracked preview-image changes to commit (drift was outside expected paths)." + git status --porcelain + exit 0 + fi + # [skip ci] prevents an infinite re-run loop; the next push to main + # will pick up these fresh images for the regular pages-build. + git commit -m "chore(preview): regenerate example-app preview images [skip ci]" + node scripts/push-main-with-rebase-retry.mjs origin main --label preview-images + + - name: Upload screenshot failure artifacts + if: failure() + uses: actions/upload-artifact@v7 + with: + name: preview-regen-failure-${{ github.run_id }} + path: | + docs/screenshots/ + web/test-results/ + web/playwright-report/ + retention-days: 7 + if-no-files-found: ignore + + - name: Summarize regeneration result + if: always() + run: | + if [[ "${{ steps.drift.outputs.drift }}" == "true" ]]; then + echo "::notice::Preview images regenerated and (if applicable) committed to main." + else + echo "::notice::Preview images are already up to date." + fi diff --git a/dev/log/issues/298/pulls/299/templates/js/links.yml b/dev/log/issues/298/pulls/299/templates/js/links.yml new file mode 100644 index 00000000..5ff315d0 --- /dev/null +++ b/dev/log/issues/298/pulls/299/templates/js/links.yml @@ -0,0 +1,104 @@ +name: Broken Link Checker + +on: + push: + branches: + - main + paths: + - '**.md' + - '**.html' + - '.github/workflows/links.yml' + pull_request: + types: [opened, synchronize, reopened] + paths: + - '**.md' + - '**.html' + - '.github/workflows/links.yml' + workflow_dispatch: + +# Least-privilege default; jobs escalate individually when needed. +permissions: + contents: read + +# Provide Git config to actions/checkout itself; checkout runs git init before +# any workflow step can configure Git. +env: + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: main + +jobs: + link-checker: + name: Check Links + runs-on: ubuntu-latest + # Typical run: <1min with lychee cache. 10min prevents slow + # external hosts or Wayback Machine probes from hanging the workflow. + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-link-checker + cancel-in-progress: true + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + + - name: Check links with lychee + id: lychee + uses: lycheeverse/lychee-action@v2 + with: + # Check all Markdown and HTML files + # Exclude case-studies directory - these are research documents from + # external repos with references to files and issues that don't exist + # in this repository (similar exclusion pattern as eslint.config.js) + # Exclude the Vite source HTML because its root-relative app asset + # URLs are only valid when served by Vite. + # Exclude tests/fixtures - the captured lychee reports there contain + # deliberately broken links used as parser test input. + args: >- + --verbose + --no-progress + --cache + --max-cache-age 1d + --max-retries 3 + --timeout 30 + --exclude-path docs/case-studies + --exclude-path examples/universal-app/index.html + --exclude-path tests/fixtures + './**/*.md' + './**/*.html' + # Don't fail the workflow immediately - we want to check web archive first + fail: false + # Output file for broken links report (used by check-web-archive.mjs) + output: lychee/out.md + # Write a job summary + jobSummary: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Check broken links against Web Archive + if: steps.lychee.outputs.exit_code != 0 + id: webarchive + run: node scripts/check-web-archive.mjs + env: + LYCHEE_OUTPUT: lychee/out.md + + - name: Fail if broken links were found + if: always() && steps.lychee.outputs.exit_code != 0 + run: | + echo "::error::Broken live links were detected." + echo "" + echo "What happened:" + echo " lychee found one or more broken links in the *.md and *.html files of this repository." + echo " An archive is a suggested replacement; it does not make the live link valid." + echo "" + echo "How to fix:" + echo " 1. Review the 'Check links with lychee' step above for a full list of broken links." + echo " 2. For links marked with a '::notice::' annotation above, a Web Archive replacement exists." + echo " Replace those broken links with the suggested archive.org URL." + echo " 3. For links with no archive version, either:" + echo " a. Find an updated URL that points to the same or equivalent content." + echo " b. Remove the link if the content is no longer relevant." + echo " c. Add the URL to .lycheeignore if it is a known false positive." + echo "" + echo "Report location: lychee/out.md (available as a workflow artifact if configured)." + exit 1 diff --git a/dev/log/issues/298/pulls/299/templates/js/release.yml b/dev/log/issues/298/pulls/299/templates/js/release.yml new file mode 100644 index 00000000..98c58adf --- /dev/null +++ b/dev/log/issues/298/pulls/299/templates/js/release.yml @@ -0,0 +1,890 @@ +name: Checks and release + +on: + push: + branches: + - main + pull_request: + types: [opened, synchronize, reopened] + # Manual release support - consolidated here to work with npm trusted publishing + # npm only allows ONE workflow file as trusted publisher, so all publishing + # must go through this workflow (release.yml) + workflow_dispatch: + inputs: + release_mode: + description: 'Manual release mode' + required: true + type: choice + default: 'instant' + options: + - instant + - changeset-pr + bump_type: + description: 'Manual release type' + required: true + type: choice + options: + - patch + - minor + - major + description: + description: 'Manual release description (optional)' + required: false + type: string + +# Least-privilege default for the highest-value token in the repository. +# Every job starts read-only; the publishing jobs escalate individually. +permissions: + contents: read + +# Provide Git config to actions/checkout itself; checkout runs git init before +# any workflow step can configure Git. +env: + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: main + +jobs: + # === DETECT CHANGES - determines which jobs should run === + detect-changes: + name: Detect Changes + runs-on: ubuntu-latest + # Typical run: ~6s. Cap at 5min so a hung detection step + # surfaces quickly instead of stalling the whole pipeline. + timeout-minutes: 5 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-detect-changes + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + if: github.event_name != 'workflow_dispatch' + outputs: + js-changed: ${{ steps.changes.outputs.js-changed }} + docs-changed: ${{ steps.changes.outputs.docs-changed }} + any-code-changed: ${{ steps.changes.outputs.any-code-changed }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Detect changes + id: changes + run: node scripts/detect-code-changes.mjs + + # === FAST CHECKS - run before slow tests for fastest feedback === + # See: hive-mind CI/CD best practices principle #5 (fast-fail job ordering) + + # Syntax check all .mjs files with node --check (~7s) + test-compilation: + name: Test Compilation + runs-on: ubuntu-latest + # Typical run: <10s. Tight cap fails fast on syntax-check hangs. + timeout-minutes: 5 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-test-compilation + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: [detect-changes] + if: | + needs.detect-changes.outputs.js-changed == 'true' + steps: + - uses: actions/checkout@v6 + + - name: Check .mjs syntax + run: bash scripts/check-mjs-syntax.sh + + # Enforce 1500-line limit on .mjs files and release.yml + check-file-line-limits: + name: Check File Line Limits + runs-on: ubuntu-latest + # Typical run: <10s. This job only walks tracked files and counts lines. + timeout-minutes: 5 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-check-file-line-limits + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: [detect-changes] + if: | + needs.detect-changes.outputs.docs-changed == 'true' || + needs.detect-changes.outputs.any-code-changed == 'true' + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Simulate fresh merge with base branch (PR only) + if: github.event_name == 'pull_request' + env: + BASE_REF: ${{ github.base_ref }} + run: bash scripts/simulate-fresh-merge.sh + + # Enforces the 1500-line limit on JavaScript (.js/.mjs/.cjs) and + # Markdown (.md) files plus release.yml. This is the single source + # of truth for the line limit; validate-docs no longer re-checks it. + - name: Check file line limits + run: bash scripts/check-file-line-limits.sh + + # === VERSION CHANGE CHECK === + # Prohibit manual version changes in package.json - versions should only be changed by CI/CD + version-check: + name: Check for Manual Version Changes + runs-on: ubuntu-latest + # Typical run: ~6s. Read-only package.json diff inspection. + timeout-minutes: 5 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-version-check + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Check for version changes in package.json + env: + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_BASE_REF: ${{ github.base_ref }} + run: node scripts/check-version.mjs + + # === CHANGESET CHECK - only runs on PRs with code changes === + # Docs-only PRs (./docs folder, markdown files) don't require changesets + changeset-check: + name: Check for Changesets + runs-on: ubuntu-latest + # Typical run: <30s including npm install. 10min covers cold runners. + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-changeset-check + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: [detect-changes] + if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any-code-changed == 'true' + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '24.x' + + - name: Install dependencies + run: npm install + + - name: Check for changesets + env: + # Pass PR context to the validation script + GITHUB_BASE_REF: ${{ github.base_ref }} + GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }} + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + # Skip changeset check for automated version PRs + if [[ "$GITHUB_HEAD_REF" == "changeset-release/"* ]]; then + echo "Skipping changeset check for automated release PR" + exit 0 + fi + + # Run changeset validation script + # This validates that exactly ONE changeset was ADDED by this PR + # Pre-existing changesets from other merged PRs are ignored + node scripts/validate-changeset.mjs + + # === LINT AND FORMAT CHECK === + # Lint runs independently of changeset-check - it's a fast check that should always run + # See: https://github.com/link-assistant/hive-mind/pull/1024 for why this dependency was removed + # IMPORTANT: ESLint includes max-lines rule (1500 lines) to ensure files stay maintainable + # See docs/case-studies/issue-23 for why fresh merge simulation is critical + lint: + name: Lint and Format Check + runs-on: ubuntu-latest + # Typical run: <1min including install, ESLint, Prettier, jscpd, + # and secretlint. 10min protects against a hung lint plugin. + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-lint + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: [detect-changes] + if: | + !cancelled() && + ( + github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.docs-changed == 'true' || + needs.detect-changes.outputs.any-code-changed == 'true' + ) + steps: + - uses: actions/checkout@v6 + with: + # For PRs, fetch enough history to merge with base branch + fetch-depth: 0 + + - name: Simulate fresh merge with base branch (PR only) + if: github.event_name == 'pull_request' + env: + BASE_REF: ${{ github.base_ref }} + run: bash scripts/simulate-fresh-merge.sh + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '24.x' + + - name: Install dependencies + run: npm install + + - name: Run ESLint + run: npm run lint + + - name: Check formatting + run: npm run format:check + + - name: Check code duplication + run: npm run check:duplication + + - name: Check for secrets + run: npx --yes -p secretlint -p @secretlint/secretlint-rule-preset-recommend secretlint "**/*" + + # Test matrix: 3 runtimes (Node.js, Bun, Deno) x 3 OS (Ubuntu, macOS, Windows) + # IMPORTANT: Tests must validate the ACTUAL merge result, not a stale merge preview. + # See docs/case-studies/issue-23 for why this is critical. + # Fast-fail: slow test matrix only runs after fast checks pass (hive-mind principle #5) + test: + name: Test (${{ matrix.runtime }} on ${{ matrix.os }}) + runs-on: ${{ matrix.os }} + # Typical run: <1min per runtime/OS on warm runners, with Windows + # sometimes slower on cold starts. Each test step owns an explicit + # budget below (see scripts/run-with-budget-warning.sh); this cap is + # only the backstop behind those budgets, sized so the budgets always + # expire first. A job killed by timeout-minutes reports *cancelled*, + # which hides the failure -- see docs/CI-TIMEOUT-BUDGETS.md. + timeout-minutes: 15 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-test-${{ matrix.runtime }}-${{ matrix.os }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: + [ + detect-changes, + changeset-check, + test-compilation, + lint, + check-file-line-limits, + ] + # Use !cancelled() instead of always() so cancellation propagates correctly (hive-mind issue #1278) + # Run for relevant code/package/workflow changes, or for an instant manual + # release. Skipped fast checks count as non-failures only after one of + # those positive gates passes. + if: | + !cancelled() && + ( + needs.detect-changes.outputs.any-code-changed == 'true' || + ( + github.event_name == 'workflow_dispatch' && + github.event.inputs.release_mode == 'instant' + ) + ) && + (needs.changeset-check.result == 'success' || needs.changeset-check.result == 'skipped') && + (needs.test-compilation.result == 'success' || needs.test-compilation.result == 'skipped') && + (needs.lint.result == 'success' || needs.lint.result == 'skipped') && + (needs.check-file-line-limits.result == 'success' || needs.check-file-line-limits.result == 'skipped') + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runtime: [node, bun, deno] + steps: + - uses: actions/checkout@v6 + with: + # For PRs, fetch enough history to merge with base branch + fetch-depth: 0 + + - name: Simulate fresh merge with base branch (PR only) + if: github.event_name == 'pull_request' + env: + BASE_REF: ${{ github.base_ref }} + shell: bash + run: bash scripts/simulate-fresh-merge.sh + + - name: Setup Node.js + if: matrix.runtime == 'node' + uses: actions/setup-node@v6 + with: + node-version: '24.x' + + - name: Install dependencies (Node.js) + if: matrix.runtime == 'node' + run: npm install + + - name: Run tests (Node.js) + if: matrix.runtime == 'node' + shell: bash + run: bash scripts/run-with-budget-warning.sh 300 "Node.js test suite" npm test + + - name: Setup Bun + if: matrix.runtime == 'bun' + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: latest + + - name: Install dependencies (Bun) + if: matrix.runtime == 'bun' + run: bun install + + - name: Run tests (Bun) + if: matrix.runtime == 'bun' + shell: bash + # --timeout caps an *individual* test at 30s, matching Node's + # --test-timeout budget while leaving headroom for cold runners. + # It is a per-test bound and does not bound the suite: 25 tests of + # 29s each pass every per-test check. The suite budget below is + # what bounds the whole run. + run: bash scripts/run-with-budget-warning.sh 200 "Bun test suite" bun test --timeout 30000 + + - name: Setup Deno + if: matrix.runtime == 'deno' + uses: denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed # v2.0.5 + with: + deno-version: v2.x + + - name: Run tests (Deno) + if: matrix.runtime == 'deno' + shell: bash + run: bash scripts/run-with-budget-warning.sh 100 "Deno test suite" deno test --allow-read + + # === DOCKER IMAGE BUILD CHECK (pull requests) === + # Builds the Dockerfile without pushing so a broken image fails the pull + # request instead of surfacing only after publish (issue #106). + # push: false + load: true keeps this working for fork pull requests, + # which have no registry credentials. Skipped when no Dockerfile exists. + docker-build: + name: Docker Image Build Check + runs-on: ubuntu-latest + # Typical run: a few minutes with a warm GHA layer cache. 30min covers + # a cold, uncached build without allowing a 6h hang. + timeout-minutes: 30 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-docker-build + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: [detect-changes] + if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any-code-changed == 'true' + permissions: + contents: read + env: + DOCKER_CONTEXT: ${{ vars.DOCKER_CONTEXT }} + DOCKERFILE: ${{ vars.DOCKERFILE }} + steps: + - uses: actions/checkout@v6 + + - name: Check Docker build configuration + id: docker_config + run: node scripts/check-docker-build.mjs + + - name: Set up Docker Buildx (resilient) + if: steps.docker_config.outputs.enabled == 'true' + uses: ./.github/actions/setup-buildx-resilient + + - name: Build Docker image (no push) + if: steps.docker_config.outputs.enabled == 'true' + # A `uses:` step cannot be wrapped by run-with-budget-warning.sh, so + # it declares its deadline with a step-level timeout-minutes: an + # exhausted step budget fails the step, while an exhausted job cap + # only cancels the job. + timeout-minutes: 20 + uses: docker/build-push-action@v7 + with: + context: ${{ steps.docker_config.outputs.context }} + file: ${{ steps.docker_config.outputs.dockerfile }} + push: false + load: true + tags: pr-check:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + + # === DOCUMENTATION VALIDATION === + # Validate documentation files when docs change (hive-mind principle #12) + validate-docs: + name: Validate Documentation + runs-on: ubuntu-latest + # Typical run: <10s. Pure shell checks over documentation files. + timeout-minutes: 5 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-validate-docs + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: [detect-changes] + if: | + needs.detect-changes.outputs.docs-changed == 'true' + steps: + - uses: actions/checkout@v6 + + # Documentation line limits (1500 lines, matching the architecture + # limit) are enforced by the check-file-line-limits job, which scans + # every .md file. This job only validates required-file presence. + - name: Check required documentation files exist + run: | + REQUIRED_FILES=( + "docs/BEST-PRACTICES.md" + "docs/CONTRIBUTING.md" + "README.md" + "CHANGELOG.md" + ) + + MISSING=() + for file in "${REQUIRED_FILES[@]}"; do + if [ ! -f "$file" ]; then + echo "ERROR: Required documentation file missing: $file" + MISSING+=("$file") + else + echo "Found: $file" + fi + done + + if [ "${#MISSING[@]}" -gt 0 ]; then + echo "" + echo "Missing required documentation files:" + printf ' %s\n' "${MISSING[@]}" + exit 1 + else + echo "All required documentation files present." + fi + + # Release - only runs on main after tests pass (for push events) + release: + name: Release + needs: [lint, test] + # Typical run is well under 10min. 30min gives npm and GitHub + # release APIs room for retries without allowing a 6h hang. + timeout-minutes: 30 + concurrency: + group: main-writer-${{ github.repository }}-main + cancel-in-progress: false + # Use !cancelled() instead of always() so cancellation propagates correctly (hive-mind issue #1278) + # This is needed because lint/test jobs have a transitive dependency on changeset-check + if: | + !cancelled() && + github.ref == 'refs/heads/main' && + github.event_name == 'push' && + needs.lint.result == 'success' && + needs.test.result == 'success' + runs-on: ubuntu-latest + # Permissions required for npm OIDC trusted publishing + permissions: + contents: write + pull-requests: write + id-token: write + outputs: + published: ${{ steps.publish.outputs.published }} + published_version: ${{ steps.publish.outputs.published_version }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '24.x' + registry-url: 'https://registry.npmjs.org' + + - name: Remove deprecated npm auth config + run: node scripts/sanitize-npm-userconfig.mjs + + - name: Install dependencies + run: bash scripts/run-with-budget-warning.sh 240 "Release dependency install" npm install + + - name: Update npm for OIDC trusted publishing + run: node scripts/setup-npm.mjs + + - name: Check for changesets + id: check_changesets + run: node scripts/check-changesets.mjs + + - name: Check if release is needed + id: check_release + env: + HAS_CHANGESETS: ${{ steps.check_changesets.outputs.has_changesets }} + run: node scripts/check-release-needed.mjs + + - name: Merge multiple changesets + if: steps.check_changesets.outputs.has_changesets == 'true' && steps.check_changesets.outputs.changeset_count > 1 + run: | + echo "Multiple changesets detected, merging..." + node scripts/merge-changesets.mjs + + - name: Version packages and commit to main + if: steps.check_changesets.outputs.has_changesets == 'true' + id: version + env: + # Lets the push helper land the version commit through a pull request + # when a repository ruleset declines the direct push (issue #143). + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node scripts/version-and-commit.mjs --mode changeset + + - name: Publish to npm + # Run if version was committed, if a previous attempt already committed (for re-runs), + # or if check-release-needed detected an unpublished version (self-healing, issue #36) + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' || + (steps.check_release.outputs.should_release == 'true' && steps.check_release.outputs.skip_bump == 'true') + id: publish + # Optional NPM_TOKEN bootstrap fallback. OIDC trusted publishing is the + # steady-state mechanism, but it cannot create a brand-new package (the + # first publish returns E404 because a trusted publisher can only be + # configured for a package that already exists). When NPM_TOKEN is set, + # the first publish succeeds; once the package exists and a trusted + # publisher is configured, OIDC takes over and the token can be removed. + # See issue #77. + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: bash scripts/run-with-budget-warning.sh 420 "npm publish" node scripts/publish-to-npm.mjs --should-pull + + - name: Smoke-test published npm package + if: steps.publish.outputs.published == 'true' + run: bash scripts/run-with-budget-warning.sh 300 "npm package smoke test" node scripts/smoke-test-package.mjs --package-version "${{ steps.publish.outputs.published_version }}" + + - name: Create GitHub Release + if: steps.publish.outputs.published == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node scripts/create-github-release.mjs --release-version "${{ steps.publish.outputs.published_version }}" --repository "${{ github.repository }}" + + - name: Format GitHub release notes + if: steps.publish.outputs.published == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node scripts/format-github-release.mjs --release-version "${{ steps.publish.outputs.published_version }}" --repository "${{ github.repository }}" --commit-sha "${{ github.sha }}" + + # Manual Instant Release - triggered via workflow_dispatch with instant mode + # This job is in release.yml because npm trusted publishing + # only allows one workflow file to be registered as a trusted publisher + instant-release: + name: Instant Release + # Publishing must wait for both quality gates and require explicit success. + needs: [lint, test] + if: | + !cancelled() && + github.event_name == 'workflow_dispatch' && + github.event.inputs.release_mode == 'instant' && + needs.lint.result == 'success' && + needs.test.result == 'success' + runs-on: ubuntu-latest + # Same publish envelope as the automated release path. + timeout-minutes: 30 + concurrency: + group: main-writer-${{ github.repository }}-main + cancel-in-progress: false + # Permissions required for npm OIDC trusted publishing + permissions: + contents: write + pull-requests: write + id-token: write + outputs: + published: ${{ steps.publish.outputs.published }} + published_version: ${{ steps.publish.outputs.published_version }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '24.x' + registry-url: 'https://registry.npmjs.org' + + - name: Remove deprecated npm auth config + run: node scripts/sanitize-npm-userconfig.mjs + + - name: Install dependencies + run: npm install + + - name: Update npm for OIDC trusted publishing + run: node scripts/setup-npm.mjs + + - name: Version packages and commit to main + id: version + env: + BUMP_TYPE: ${{ github.event.inputs.bump_type }} + RELEASE_DESCRIPTION: ${{ github.event.inputs.description }} + # See the release job above (issue #143). + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node scripts/version-and-commit.mjs --mode instant --bump-type "$BUMP_TYPE" --description "$RELEASE_DESCRIPTION" + + - name: Publish to npm + # Run if version was committed OR if a previous attempt already committed (for re-runs) + if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' + id: publish + # Optional NPM_TOKEN bootstrap fallback; OIDC trusted publishing is used + # when the secret is unset. See the release job above for details (#77). + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: node scripts/publish-to-npm.mjs + + - name: Smoke-test published npm package + if: steps.publish.outputs.published == 'true' + run: node scripts/smoke-test-package.mjs --package-version "${{ steps.publish.outputs.published_version }}" + + - name: Create GitHub Release + if: steps.publish.outputs.published == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node scripts/create-github-release.mjs --release-version "${{ steps.publish.outputs.published_version }}" --repository "${{ github.repository }}" + + - name: Format GitHub release notes + if: steps.publish.outputs.published == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node scripts/format-github-release.mjs --release-version "${{ steps.publish.outputs.published_version }}" --repository "${{ github.repository }}" --commit-sha "${{ github.sha }}" + + # Optional Docker Hub publishing for packages that also ship Docker images. + # Set vars.DOCKERHUB_IMAGE to enable this path, then configure + # vars.DOCKERHUB_USERNAME and secrets.DOCKERHUB_TOKEN. + docker-publish-config: + name: Configure Docker Hub Publish + needs: [release, instant-release] + timeout-minutes: 10 + concurrency: + group: main-writer-${{ github.repository }}-main + cancel-in-progress: false + if: | + !cancelled() && + ( + (needs.release.result == 'success' && needs.release.outputs.published == 'true') || + (needs.instant-release.result == 'success' && needs.instant-release.outputs.published == 'true') + ) + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + context: ${{ steps.docker_config.outputs.context }} + dockerfile: ${{ steps.docker_config.outputs.dockerfile }} + enabled: ${{ steps.docker_config.outputs.enabled }} + image: ${{ steps.docker_config.outputs.image }} + version: ${{ steps.release_version.outputs.version }} + env: + DOCKER_CONTEXT: ${{ vars.DOCKER_CONTEXT }} + DOCKERFILE: ${{ vars.DOCKERFILE }} + DOCKERHUB_IMAGE: ${{ vars.DOCKERHUB_IMAGE }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + DOCKERHUB_USERNAME: ${{ vars.DOCKERHUB_USERNAME }} + RELEASE_VERSION: ${{ needs.release.outputs.published_version || needs.instant-release.outputs.published_version }} + steps: + - uses: actions/checkout@v6 + + - name: Check Docker publish configuration + id: docker_config + run: node scripts/check-docker-publish.mjs + + - name: Export release version + id: release_version + env: + VERSION: ${{ env.RELEASE_VERSION }} + run: echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + - name: Wait for npm package availability before Docker publish + if: steps.docker_config.outputs.enabled == 'true' + env: + VERSION: ${{ env.RELEASE_VERSION }} + run: node scripts/wait-for-npm.mjs --release-version "${VERSION}" + + # Build each architecture on a native runner. Each build is pushed by digest; + # docker-publish combines those immutable digests into the release tags. + docker-publish-build: + name: Build Docker Image (${{ matrix.platform }}) + needs: [docker-publish-config] + if: needs.docker-publish-config.outputs.enabled == 'true' + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + + - name: Build and push platform image by digest + id: build + # Step-level budget below the job's timeout-minutes backstop; see + # docs/CI-TIMEOUT-BUDGETS.md. + timeout-minutes: 20 + uses: ./.github/actions/publish-dockerhub + with: + context: ${{ needs.docker-publish-config.outputs.context }} + file: ${{ needs.docker-publish-config.outputs.dockerfile }} + image: ${{ needs.docker-publish-config.outputs.image }} + platform: ${{ matrix.platform }} + token: ${{ secrets.DOCKERHUB_TOKEN }} + username: ${{ vars.DOCKERHUB_USERNAME }} + version: ${{ needs.docker-publish-config.outputs.version }} + + - name: Export image digest + env: + DIGEST: ${{ steps.build.outputs.digest }} + run: | + mkdir -p /tmp/digests + touch "/tmp/digests/${DIGEST#sha256:}" + + - name: Upload image digest + uses: actions/upload-artifact@v7 + with: + name: docker-digest-${{ strategy.job-index }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + docker-publish: + name: Optional Docker Hub Publish + needs: [docker-publish-config, docker-publish-build] + if: needs.docker-publish-build.result == 'success' + timeout-minutes: 30 + concurrency: + group: main-writer-${{ github.repository }}-main + cancel-in-progress: false + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + + - name: Set up Docker Buildx (resilient) + uses: ./.github/actions/setup-buildx-resilient + + - name: Log in to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Download image digests + uses: actions/download-artifact@v8 + env: + NODE_OPTIONS: --disable-warning=DEP0005 + with: + path: /tmp/digests + pattern: docker-digest-* + merge-multiple: true + + - name: Create multi-architecture manifest + working-directory: /tmp/digests + env: + IMAGE: ${{ needs.docker-publish-config.outputs.image }} + VERSION: ${{ needs.docker-publish-config.outputs.version }} + run: | + # One argument per digest. An array keeps the expansion explicit + # instead of relying on word splitting of a command substitution. + mapfile -t digests < <(printf "${IMAGE}@sha256:%s\n" *) + docker buildx imagetools create \ + --tag "${IMAGE}:latest" \ + --tag "${IMAGE}:${VERSION}" \ + "${digests[@]}" + + # Manual Changeset PR - creates a pull request with the changeset for review + changeset-pr: + name: Create Changeset PR + # PR creation does not publish, but it must still pass the fast lint gate. + needs: [lint] + if: | + !cancelled() && + github.event_name == 'workflow_dispatch' && + github.event.inputs.release_mode == 'changeset-pr' && + needs.lint.result == 'success' + runs-on: ubuntu-latest + # PR creation only: install, create a changeset, format, and open a PR. + timeout-minutes: 10 + concurrency: + group: main-writer-${{ github.repository }}-main + cancel-in-progress: false + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '24.x' + + - name: Install dependencies + run: npm install + + - name: Create changeset file + env: + BUMP_TYPE: ${{ github.event.inputs.bump_type }} + RELEASE_DESCRIPTION: ${{ github.event.inputs.description }} + run: node scripts/create-manual-changeset.mjs --bump-type "$BUMP_TYPE" --description "$RELEASE_DESCRIPTION" + + - name: Format changeset with Prettier + run: | + # Run Prettier on the changeset file to ensure it matches project style + npx prettier --write ".changeset/*.md" || true + + echo "Formatted changeset files" + + - name: Create Pull Request + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: 'chore: add changeset for manual ${{ github.event.inputs.bump_type }} release' + branch: changeset-manual-release-${{ github.run_id }} + delete-branch: true + title: 'chore: manual ${{ github.event.inputs.bump_type }} release' + body: | + ## Manual Release Request + + This PR was created by a manual workflow trigger to prepare a **${{ github.event.inputs.bump_type }}** release. + + ### Release Details + - **Type:** ${{ github.event.inputs.bump_type }} + - **Description:** ${{ github.event.inputs.description || 'Manual release' }} + - **Triggered by:** @${{ github.actor }} + + ### Next Steps + 1. Review the changeset in this PR + 2. Merge this PR to main + 3. The automated release workflow will create a version PR + 4. Merge the version PR to publish to npm and create a GitHub release + + # GitHub reports jobs killed by timeout-minutes as cancelled rather than + # failed. Observe every job so those cancellations become visible failures + # on main, where concurrency never supersedes an in-progress run. + pipeline-status: + name: Pipeline Status + runs-on: ubuntu-latest + timeout-minutes: 5 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-pipeline-status + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + if: always() + needs: + - detect-changes + - test-compilation + - check-file-line-limits + - version-check + - changeset-check + - lint + - test + - docker-build + - docker-publish-config + - docker-publish-build + - validate-docs + - release + - instant-release + - docker-publish + - changeset-pr + steps: + - uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '24.x' + + - name: Fail the run when a required job was cancelled or failed + env: + NEEDS_JSON: ${{ toJSON(needs) }} + IS_MAIN: ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' }} + run: bash scripts/check-pipeline-status.sh diff --git a/dev/log/issues/298/pulls/299/templates/js/security.yml b/dev/log/issues/298/pulls/299/templates/js/security.yml new file mode 100644 index 00000000..6c0e4219 --- /dev/null +++ b/dev/log/issues/298/pulls/299/templates/js/security.yml @@ -0,0 +1,93 @@ +name: Security + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened] + schedule: + - cron: '0 6 * * 1' + workflow_dispatch: + +# Least-privilege default; jobs escalate individually when needed. +permissions: + contents: read + +# Provide Git config to actions/checkout itself; checkout runs git init before +# any workflow step can configure Git. +env: + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: main + +jobs: + codeql: + name: CodeQL (${{ matrix.language }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-codeql-${{ matrix.language }} + cancel-in-progress: true + permissions: + actions: read + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + language: [javascript-typescript, actions] + steps: + - uses: actions/checkout@v6 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + + - name: Autobuild + uses: github/codeql-action/autobuild@v4 + + - name: Analyze + uses: github/codeql-action/analyze@v4 + + dependency-review: + name: Dependency Review + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-dependency-review + cancel-in-progress: true + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v6 + + - name: Review dependency changes + uses: actions/dependency-review-action@v5 + with: + fail-on-severity: high + comment-summary-in-pr: on-failure + + npm-audit: + name: Audit npm lock (${{ matrix.directory }}) + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-npm-audit-${{ matrix.directory }} + cancel-in-progress: true + strategy: + fail-fast: false + matrix: + directory: ['.', examples/universal-app] + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Audit current lock + working-directory: ${{ matrix.directory }} + run: npm audit --package-lock-only --audit-level=high diff --git a/dev/log/issues/298/pulls/299/templates/js/workflows.yml b/dev/log/issues/298/pulls/299/templates/js/workflows.yml new file mode 100644 index 00000000..2a4ae4e3 --- /dev/null +++ b/dev/log/issues/298/pulls/299/templates/js/workflows.yml @@ -0,0 +1,69 @@ +name: Workflows + +on: + push: + branches: + - main + paths: + - '.github/workflows/**' + - '.github/zizmor.yml' + pull_request: + types: [opened, synchronize, reopened] + paths: + - '.github/workflows/**' + - '.github/zizmor.yml' + workflow_dispatch: + +# Least-privilege default; these jobs only need to read the checked-out tree. +permissions: + contents: read + +# Provide Git config to actions/checkout itself; checkout runs git init before +# any workflow step can configure Git. +env: + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: main + +jobs: + actionlint: + name: actionlint + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-actionlint + cancel-in-progress: true + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + # The Docker image bundles shellcheck and pyflakes, so this also lints + # every `run:` block. A native actionlint binary without shellcheck on + # PATH silently skips the shell checks and exits 0 - worth knowing when + # reproducing a failure locally: + # docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:1.7.7 -color + - uses: docker://rhysd/actionlint:1.7.7 + with: + args: -color + + zizmor: + name: zizmor + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-zizmor + cancel-in-progress: true + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + # Annotations instead of SARIF: forks of this template do not necessarily + # have code scanning enabled, and the job should fail loudly either way. + - uses: zizmorcore/zizmor-action@v0.6.2 + with: + advanced-security: false + annotations: true + config: .github/zizmor.yml + min-confidence: medium diff --git a/dev/log/issues/298/pulls/299/templates/php-workflows.txt b/dev/log/issues/298/pulls/299/templates/php-workflows.txt new file mode 100644 index 00000000..9824c1ec --- /dev/null +++ b/dev/log/issues/298/pulls/299/templates/php-workflows.txt @@ -0,0 +1,3 @@ +docs.yml +links.yml +release.yml diff --git a/dev/log/issues/298/pulls/299/templates/php/docs.yml b/dev/log/issues/298/pulls/299/templates/php/docs.yml new file mode 100644 index 00000000..728198cf --- /dev/null +++ b/dev/log/issues/298/pulls/299/templates/php/docs.yml @@ -0,0 +1,84 @@ +name: Docs + +# Builds API documentation with phpDocumentor on every push/PR that touches +# src/, docs/ or this workflow. On pushes to main the rendered site is +# published to GitHub Pages; PRs only build to verify the docs still compile. +# +# One-time setup: in Settings -> Pages, set Source to "GitHub Actions". +# Without it the first deploy fails on actions/deploy-pages with +# "Get Pages site failed". This cannot be configured from a workflow. + +on: + push: + branches: [main] + paths: + - 'docs/**' + - 'src/**' + - 'composer.json' + - '.github/workflows/docs.yml' + pull_request: + types: [opened, synchronize, reopened] + paths: + - 'docs/**' + - 'src/**' + - 'composer.json' + - '.github/workflows/docs.yml' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + build: + name: Build docs + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + coverage: none + tools: phpDocumentor + + - name: Build API documentation + run: phpDocumentor --directory=src --target=_site --title="$(php -r "echo json_decode(file_get_contents('composer.json'))->name;")" + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: docs-site + path: _site + if-no-files-found: error + + - name: Configure GitHub Pages + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: actions/configure-pages@v5 + + - name: Upload GitHub Pages artifact + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: actions/upload-pages-artifact@v3 + with: + path: _site + + deploy: + name: Deploy to GitHub Pages + needs: [build] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy Pages artifact + id: deployment + uses: actions/deploy-pages@v4 diff --git a/dev/log/issues/298/pulls/299/templates/php/links.yml b/dev/log/issues/298/pulls/299/templates/php/links.yml new file mode 100644 index 00000000..d62a9289 --- /dev/null +++ b/dev/log/issues/298/pulls/299/templates/php/links.yml @@ -0,0 +1,78 @@ +name: Broken Link Checker + +on: + push: + branches: + - main + paths: + - '**.md' + - '**.html' + - '.github/workflows/links.yml' + pull_request: + types: [opened, synchronize, reopened] + paths: + - '**.md' + - '**.html' + - '.github/workflows/links.yml' + workflow_dispatch: + +jobs: + link-checker: + name: Check Links + runs-on: ubuntu-latest + # Typical run: <1min with the lychee cache. 10min prevents slow external + # hosts or Wayback Machine probes from hanging the workflow. + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + coverage: none + + - name: Check links with lychee + id: lychee + uses: lycheeverse/lychee-action@v2 + with: + # The case-studies directory holds research copied from sibling repos + # whose relative links resolve there, not here; exclude it. + args: >- + --verbose + --no-progress + --cache + --max-cache-age 1d + --max-retries 3 + --timeout 30 + --exclude-path docs/case-studies + './**/*.md' + './**/*.html' + fail: false + output: lychee/out.md + jobSummary: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Check broken links against the Web Archive + if: steps.lychee.outputs.exit_code != 0 + id: webarchive + env: + LYCHEE_OUTPUT: lychee/out.md + run: php scripts/check-web-archive.php + + - name: Fail if broken links found and no web archive fallback + if: steps.lychee.outputs.exit_code != 0 && steps.webarchive.outputs.all_archived != 'true' + run: | + echo "::error::Broken links were detected with no Web Archive fallback available." + echo "" + echo "How to fix:" + echo " 1. Review the 'Check links with lychee' step above for the full list." + echo " 2. For links with a '::notice::' annotation, a Web Archive copy exists —" + echo " replace the broken link with the suggested archive.org URL." + echo " 3. Otherwise update the URL, remove the link, or add it to .lycheeignore." + echo "" + echo "Report location: lychee/out.md" + exit 1 diff --git a/dev/log/issues/298/pulls/299/templates/php/release.yml b/dev/log/issues/298/pulls/299/templates/php/release.yml new file mode 100644 index 00000000..94fb10b7 --- /dev/null +++ b/dev/log/issues/298/pulls/299/templates/php/release.yml @@ -0,0 +1,332 @@ +name: CI/CD Pipeline + +on: + push: + branches: + - main + pull_request: + types: [opened, synchronize, reopened] + workflow_dispatch: + inputs: + bump_type: + description: 'Version bump type' + required: true + type: choice + options: + - patch + - minor + - major + description: + description: 'Release description (optional)' + required: false + type: string + +# Never cancel an in-progress run on main (a release may be mid-flight); do +# cancel superseded PR runs. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +permissions: + contents: read + +env: + PHP_VERSION: '8.3' + +jobs: + # === DETECT CHANGES - determines which downstream jobs need to run === + detect-changes: + name: Detect Changes + runs-on: ubuntu-latest + timeout-minutes: 5 + if: github.event_name != 'workflow_dispatch' + outputs: + php-changed: ${{ steps.changes.outputs.php-changed }} + tests-changed: ${{ steps.changes.outputs.tests-changed }} + package-changed: ${{ steps.changes.outputs.package-changed }} + docs-changed: ${{ steps.changes.outputs.docs-changed }} + workflow-changed: ${{ steps.changes.outputs.workflow-changed }} + any-code-changed: ${{ steps.changes.outputs.any-code-changed }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ env.PHP_VERSION }} + coverage: none + + - name: Detect changes + id: changes + env: + GITHUB_EVENT_NAME: ${{ github.event_name }} + GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }} + GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: php scripts/detect-code-changes.php + + # === LINT AND STATIC ANALYSIS === + # Independent of the changeset check so a fast quality gate always runs. + lint: + name: Lint and Static Analysis + runs-on: ubuntu-latest + timeout-minutes: 20 + needs: [detect-changes] + # always() && !cancelled() lets this evaluate even though detect-changes is + # skipped for workflow_dispatch; otherwise the skip would propagate here. + if: | + always() && !cancelled() && ( + github.event_name == 'push' || + github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.php-changed == 'true' || + needs.detect-changes.outputs.tests-changed == 'true' || + needs.detect-changes.outputs.docs-changed == 'true' || + needs.detect-changes.outputs.package-changed == 'true' || + needs.detect-changes.outputs.workflow-changed == 'true' + ) + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ env.PHP_VERSION }} + coverage: none + tools: composer:v2 + + - name: Validate composer.json + # --no-check-version: the version field is intentionally present and + # owned by the release pipeline, which is at odds with Packagist's + # "omit version" recommendation; keep strict checks for everything else. + run: composer validate --strict --no-check-version + + - name: Install dependencies + uses: ramsey/composer-install@v3 + + - name: Check coding standards (PHP-CS-Fixer) + run: composer lint + + - name: Static analysis (PHPStan) + run: composer analyse + + - name: Check file size limit + run: composer check:file-size + + # === TEST === + test: + name: Test (PHP ${{ matrix.php }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + needs: [detect-changes] + if: | + always() && !cancelled() && ( + github.event_name == 'push' || + github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.php-changed == 'true' || + needs.detect-changes.outputs.tests-changed == 'true' || + needs.detect-changes.outputs.package-changed == 'true' || + needs.detect-changes.outputs.workflow-changed == 'true' + ) + strategy: + fail-fast: false + matrix: + php: ['8.1', '8.2', '8.3', '8.4'] + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: xdebug + tools: composer:v2 + + - name: Install dependencies + uses: ramsey/composer-install@v3 + + - name: Run tests + run: php vendor/bin/phpunit --coverage-text + + # === CHANGESET CHECK - only on PRs that touch code === + # Docs-only PRs are exempt (no changelog fragment required). + changeset: + name: Changeset Check + runs-on: ubuntu-latest + timeout-minutes: 10 + needs: [detect-changes] + if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any-code-changed == 'true' + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ env.PHP_VERSION }} + coverage: none + tools: composer:v2 + + - name: Install dependencies + uses: ramsey/composer-install@v3 + + - name: Ensure the version was not bumped by hand + env: + GITHUB_BASE_REF: ${{ github.base_ref }} + run: php scripts/check-version-modification.php + + - name: Validate changelog fragment + env: + GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }} + GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: php scripts/validate-changeset.php + + # === BUILD === + # Verifies the package installs cleanly for consumers; runs if lint+test + # succeeded or were skipped (docs-only PR). + build: + name: Build + runs-on: ubuntu-latest + timeout-minutes: 20 + needs: [detect-changes, lint, test] + if: | + always() && ( + github.event_name == 'push' || + github.event_name == 'workflow_dispatch' || + ( + (needs.lint.result == 'success' || needs.lint.result == 'skipped') && + (needs.test.result == 'success' || needs.test.result == 'skipped') + ) + ) + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ env.PHP_VERSION }} + coverage: none + tools: composer:v2 + + - name: Validate composer.json + # --no-check-version: the version field is intentionally present and + # owned by the release pipeline, which is at odds with Packagist's + # "omit version" recommendation; keep strict checks for everything else. + run: composer validate --strict --no-check-version + + - name: Install dependencies (no dev, as a consumer would) + run: composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader + + - name: Verify autoloading works + run: php -r "require 'vendor/autoload.php'; echo (new LinkFoundation\Template\Calculator())->add(2, 3), PHP_EOL;" + + # === AUTO RELEASE - push to main === + # Idempotent and self-healing: Packagist + GitHub Releases are the source of + # truth, not git tags. Re-running after a partial failure resumes safely. + auto-release: + name: Auto Release + needs: [lint, test, build] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ env.PHP_VERSION }} + coverage: none + tools: composer:v2 + + - name: Install dependencies + uses: ramsey/composer-install@v3 + + - name: Decide whether a release is needed + id: decide + env: + GITHUB_REPOSITORY: ${{ github.repository }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: php scripts/check-release-needed.php + + - name: Version, changelog, commit and tag + id: version + if: steps.decide.outputs.should_release == 'true' + run: | + if [ "${{ steps.decide.outputs.skip_bump }}" = "true" ]; then + php scripts/version-and-commit.php --skip-bump + else + php scripts/version-and-commit.php --mode=changeset + fi + + - name: Wait for Packagist to import the release + if: steps.version.outputs.new_version != '' + env: + GITHUB_REPOSITORY: ${{ github.repository }} + run: php scripts/wait-for-packagist.php --version "${{ steps.version.outputs.new_version }}" + + - name: Create GitHub release + if: steps.version.outputs.new_version != '' + env: + GITHUB_REPOSITORY: ${{ github.repository }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: php scripts/create-github-release.php --version "${{ steps.version.outputs.new_version }}" + + # === MANUAL RELEASE - workflow_dispatch === + manual-release: + name: Manual Release + needs: [lint, test, build] + # always() && !cancelled() prevents the skipped detect-changes dependency + # from propagating; explicit result checks gate release behind green CI. + if: | + always() && !cancelled() && + github.event_name == 'workflow_dispatch' && + needs.lint.result == 'success' && + needs.test.result == 'success' && + needs.build.result == 'success' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ env.PHP_VERSION }} + coverage: none + tools: composer:v2 + + - name: Install dependencies + uses: ramsey/composer-install@v3 + + - name: Version, changelog, commit and tag + id: version + run: | + php scripts/version-and-commit.php \ + --mode=instant \ + --bump="${{ github.event.inputs.bump_type }}" \ + --description="${{ github.event.inputs.description }}" + + - name: Wait for Packagist to import the release + if: steps.version.outputs.new_version != '' + env: + GITHUB_REPOSITORY: ${{ github.repository }} + run: php scripts/wait-for-packagist.php --version "${{ steps.version.outputs.new_version }}" + + - name: Create GitHub release + if: steps.version.outputs.new_version != '' + env: + GITHUB_REPOSITORY: ${{ github.repository }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: php scripts/create-github-release.php --version "${{ steps.version.outputs.new_version }}" diff --git a/dev/log/issues/298/pulls/299/templates/python-workflows.txt b/dev/log/issues/298/pulls/299/templates/python-workflows.txt new file mode 100644 index 00000000..30ba50af --- /dev/null +++ b/dev/log/issues/298/pulls/299/templates/python-workflows.txt @@ -0,0 +1,5 @@ +docs.yml +links.yml +release.yml +security.yml +workflows.yml diff --git a/dev/log/issues/298/pulls/299/templates/python/docs.yml b/dev/log/issues/298/pulls/299/templates/python/docs.yml new file mode 100644 index 00000000..94697c59 --- /dev/null +++ b/dev/log/issues/298/pulls/299/templates/python/docs.yml @@ -0,0 +1,95 @@ +name: Docs + +# Builds Sphinx documentation on every push and PR that touches docs/, src/, or +# this workflow. On pushes to main the rendered site is published to GitHub +# Pages only after deployment is explicitly enabled. PRs only build to verify +# the docs still compile. +# +# One-time setup: in the repository's Settings -> Pages, set Source to +# "GitHub Actions", then set the DEPLOY_GITHUB_PAGES repository variable to +# "true". Without the variable, deployment is skipped with a notice. Without +# Pages configured, actions/deploy-pages fails with "Get Pages site failed". +# See README.md "Deploying API documentation". + +on: + push: + branches: [main] + paths: + - 'docs/**' + - 'src/**' + - 'pyproject.toml' + - '.github/workflows/docs.yml' + pull_request: + types: [opened, synchronize, reopened] + paths: + - 'docs/**' + - 'src/**' + - 'pyproject.toml' + - '.github/workflows/docs.yml' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + build: + name: Build docs + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: '3.13' + + - name: Install package and docs dependencies + run: | + python -m pip install --upgrade pip + pip install -e . + pip install -r docs/requirements.txt + + - name: Build Sphinx documentation + run: sphinx-build -W --keep-going -b html docs _site + + - name: Upload build artifact + uses: actions/upload-artifact@v7 + with: + name: docs-site + path: _site + if-no-files-found: error + + - name: Report skipped GitHub Pages deployment + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && vars.DEPLOY_GITHUB_PAGES != 'true' + run: echo "::notice::Skipping GitHub Pages deployment. To enable publishing, set Settings -> Pages -> Source = GitHub Actions, then add repository variable DEPLOY_GITHUB_PAGES=true." + + - name: Configure GitHub Pages + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && vars.DEPLOY_GITHUB_PAGES == 'true' + uses: actions/configure-pages@v6 + + - name: Upload GitHub Pages artifact + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && vars.DEPLOY_GITHUB_PAGES == 'true' + uses: actions/upload-pages-artifact@v5 + with: + path: _site + + deploy: + name: Deploy to GitHub Pages + needs: [build] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && vars.DEPLOY_GITHUB_PAGES == 'true' + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy Pages artifact + id: deployment + uses: actions/deploy-pages@v5 diff --git a/dev/log/issues/298/pulls/299/templates/python/links.yml b/dev/log/issues/298/pulls/299/templates/python/links.yml new file mode 100644 index 00000000..4a8387d9 --- /dev/null +++ b/dev/log/issues/298/pulls/299/templates/python/links.yml @@ -0,0 +1,82 @@ +name: Broken Link Checker + +on: + push: + branches: + - main + paths: + - '**.md' + - '**.html' + - '.github/workflows/links.yml' + pull_request: + types: [opened, synchronize, reopened] + paths: + - '**.md' + - '**.html' + - '.github/workflows/links.yml' + workflow_dispatch: + +permissions: + contents: read + +env: + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: main + +jobs: + link-checker: + name: Check Links + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-link-checker + cancel-in-progress: true + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + + - name: Check links with lychee + id: lychee + uses: lycheeverse/lychee-action@v2 + with: + args: >- + --verbose + --no-progress + --cache + --max-cache-age 1d + --max-retries 3 + --timeout 30 + --exclude-path docs/case-studies + './**/*.md' + './**/*.html' + fail: false + output: lychee/out.md + jobSummary: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Check broken links against Web Archive + if: steps.lychee.outputs.exit_code != 0 + id: webarchive + run: python scripts/check_web_archive.py + env: + LYCHEE_OUTPUT: lychee/out.md + + - name: Fail if broken links were found + if: always() && steps.lychee.outputs.exit_code != 0 + run: | + echo "::error::Broken live links were detected." + echo "" + echo "What happened:" + echo " lychee found one or more broken links in the *.md and *.html files of this repository." + echo " An available Web Archive snapshot is a suggested replacement; it does not make the live link valid." + echo "" + echo "How to fix:" + echo " 1. Review the 'Check links with lychee' step above for the broken links." + echo " 2. Replace links with suggested archive.org URLs when available." + echo " 3. Otherwise find an updated URL, remove the link, or add a known false positive to .lycheeignore." + echo "" + echo "Report location: lychee/out.md." + exit 1 diff --git a/dev/log/issues/298/pulls/299/templates/python/release.yml b/dev/log/issues/298/pulls/299/templates/python/release.yml new file mode 100644 index 00000000..210d429a --- /dev/null +++ b/dev/log/issues/298/pulls/299/templates/python/release.yml @@ -0,0 +1,897 @@ +name: CI/CD Pipeline + +on: + push: + branches: + - main + pull_request: + types: [opened, synchronize, reopened] + workflow_dispatch: + inputs: + bump_type: + description: 'Version bump type' + required: true + type: choice + options: + - patch + - minor + - major + description: + description: 'Release description (optional)' + required: false + type: string + +permissions: + contents: read + +env: + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: main + +# Concurrency is intentionally job-scoped. Superseded read-only jobs cancel +# independently away from main. Jobs on main are never superseded, which lets +# the terminal status gate reliably treat a cancellation there as a timeout or +# manual cancellation. Write-capable release jobs share one non-cancelling +# concurrency group: a write that has started is never interrupted, and GitHub +# holds at most one pending run for the group. GitHub Actions accepts only +# `group` and `cancel-in-progress` here -- there is no queue-depth key. +jobs: + # === DETECT CHANGES - determines which jobs should run === + detect-changes: + name: Detect Changes + runs-on: ubuntu-latest + timeout-minutes: 5 + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-detect-changes + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + if: github.event_name != 'workflow_dispatch' + outputs: + any-code-changed: ${{ steps.changes.outputs.any-code-changed }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: '3.13' + + - name: Detect Python layout + id: python_layout + run: | + if [ -f pyproject.toml ]; then + PYTHON_ROOT="." + MULTI_LANGUAGE="false" + echo "root=." >> "$GITHUB_OUTPUT" + echo "multi_language=false" >> "$GITHUB_OUTPUT" + elif [ -f python/pyproject.toml ]; then + PYTHON_ROOT="python" + MULTI_LANGUAGE="true" + echo "root=python" >> "$GITHUB_OUTPUT" + echo "multi_language=true" >> "$GITHUB_OUTPUT" + else + echo "::error::Could not find pyproject.toml at repo root or python/pyproject.toml" + exit 1 + fi + + if [ "$PYTHON_ROOT" = "." ]; then + echo "dist_dir=dist" >> "$GITHUB_OUTPUT" + else + echo "dist_dir=$PYTHON_ROOT/dist" >> "$GITHUB_OUTPUT" + fi + echo "Detected Python layout: root=$PYTHON_ROOT, multi_language=$MULTI_LANGUAGE" + + - name: Detect changes + id: changes + env: + GITHUB_EVENT_NAME: ${{ github.event_name }} + GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }} + GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: python "${{ steps.python_layout.outputs.root }}/scripts/detect_code_changes.py" + + # REQUIRED CI CHECKS - All must pass before release + # These jobs ensure code quality and tests pass before any release + + # === LINT AND FORMAT CHECK === + # Lint runs independently of changelog check for detected code changes. + # See: https://github.com/link-assistant/hive-mind/pull/1024 for why this dependency was removed + lint: + name: Lint and Format Check + runs-on: ubuntu-latest + timeout-minutes: 20 + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-lint + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: [detect-changes] + # !cancelled() lets this job evaluate even though detect-changes is skipped + # for workflow_dispatch while still propagating workflow cancellation. + if: | + !cancelled() && ( + github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.any-code-changed == 'true' + ) + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Simulate fresh merge with base branch (PR only) + if: github.event_name == 'pull_request' + env: + BASE_REF: ${{ github.base_ref }} + run: bash scripts/simulate-fresh-merge.sh + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: '3.13' + + - name: Detect Python layout + id: python_layout + run: | + if [ -f pyproject.toml ]; then + PYTHON_ROOT="." + MULTI_LANGUAGE="false" + echo "root=." >> "$GITHUB_OUTPUT" + echo "multi_language=false" >> "$GITHUB_OUTPUT" + elif [ -f python/pyproject.toml ]; then + PYTHON_ROOT="python" + MULTI_LANGUAGE="true" + echo "root=python" >> "$GITHUB_OUTPUT" + echo "multi_language=true" >> "$GITHUB_OUTPUT" + else + echo "::error::Could not find pyproject.toml at repo root or python/pyproject.toml" + exit 1 + fi + + if [ "$PYTHON_ROOT" = "." ]; then + echo "dist_dir=dist" >> "$GITHUB_OUTPUT" + else + echo "dist_dir=$PYTHON_ROOT/dist" >> "$GITHUB_OUTPUT" + fi + echo "Detected Python layout: root=$PYTHON_ROOT, multi_language=$MULTI_LANGUAGE" + + - name: Install dependencies + env: + INSTALL_BUDGET_SECONDS: 300 + run: | + cd "${{ steps.python_layout.outputs.root }}" + bash scripts/run-with-budget-warning.sh \ + "$INSTALL_BUDGET_SECONDS" "Install dependencies" \ + bash -euo pipefail -c ' + python -m pip install --upgrade pip + pip install -e ".[dev]" + ' + + - name: Run Ruff linting + run: | + cd "${{ steps.python_layout.outputs.root }}" + ruff check . + + - name: Check Ruff formatting + run: | + cd "${{ steps.python_layout.outputs.root }}" + ruff format --check . + + - name: Run mypy + run: | + cd "${{ steps.python_layout.outputs.root }}" + mypy src + + - name: Check file size limit + run: | + cd "${{ steps.python_layout.outputs.root }}" + python scripts/check_file_size.py + + - name: Check for secrets + env: + SECRETLINT_BUDGET_SECONDS: 300 + run: | + bash "${{ steps.python_layout.outputs.root }}/scripts/run-with-budget-warning.sh" \ + "$SECRETLINT_BUDGET_SECONDS" "Check for secrets" \ + npx --yes -p secretlint -p @secretlint/secretlint-rule-preset-recommend secretlint "**/*" + + # === TEST === + # Test on latest Python version only + test: + name: Test (Python 3.13) + runs-on: ubuntu-latest + timeout-minutes: 30 + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-test + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: [detect-changes] + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + # !cancelled() lets this job evaluate even though detect-changes is skipped + # for workflow_dispatch while still propagating workflow cancellation. + if: | + !cancelled() && ( + github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.any-code-changed == 'true' + ) + steps: + - uses: actions/checkout@v6 + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: '3.13' + + - name: Detect Python layout + id: python_layout + run: | + if [ -f pyproject.toml ]; then + PYTHON_ROOT="." + MULTI_LANGUAGE="false" + echo "root=." >> "$GITHUB_OUTPUT" + echo "multi_language=false" >> "$GITHUB_OUTPUT" + elif [ -f python/pyproject.toml ]; then + PYTHON_ROOT="python" + MULTI_LANGUAGE="true" + echo "root=python" >> "$GITHUB_OUTPUT" + echo "multi_language=true" >> "$GITHUB_OUTPUT" + else + echo "::error::Could not find pyproject.toml at repo root or python/pyproject.toml" + exit 1 + fi + + if [ "$PYTHON_ROOT" = "." ]; then + echo "dist_dir=dist" >> "$GITHUB_OUTPUT" + else + echo "dist_dir=$PYTHON_ROOT/dist" >> "$GITHUB_OUTPUT" + fi + echo "Detected Python layout: root=$PYTHON_ROOT, multi_language=$MULTI_LANGUAGE" + + - name: Install dependencies + env: + INSTALL_BUDGET_SECONDS: 300 + run: | + cd "${{ steps.python_layout.outputs.root }}" + bash scripts/run-with-budget-warning.sh \ + "$INSTALL_BUDGET_SECONDS" "Install dependencies" \ + bash -euo pipefail -c ' + python -m pip install --upgrade pip + pip install -e ".[dev]" + ' + + - name: Run tests + env: + TEST_BUDGET_SECONDS: 900 + run: | + cd "${{ steps.python_layout.outputs.root }}" + bash scripts/run-with-budget-warning.sh \ + "$TEST_BUDGET_SECONDS" "Run tests" \ + pytest tests/ -v --cov=src --cov-report=xml --cov-report=term + + - name: Report skipped Codecov upload + if: env.CODECOV_TOKEN == '' + run: echo "::notice::Skipping Codecov upload because CODECOV_TOKEN is not configured" + + - name: Upload coverage to Codecov + if: env.CODECOV_TOKEN != '' + uses: codecov/codecov-action@v7 + with: + files: ${{ steps.python_layout.outputs.root }}/coverage.xml + token: ${{ env.CODECOV_TOKEN }} + disable_search: true + fail_ci_if_error: true + + # === BUILD PACKAGE === + # Build package after required checks pass for detected code changes or dispatches. + build: + name: Build Package + runs-on: ubuntu-latest + timeout-minutes: 20 + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-build + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: [detect-changes, lint, test] + # Build change-bearing automatic events and all manually dispatched releases. + if: | + !cancelled() && ( + github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.any-code-changed == 'true' + ) && ( + needs.lint.result == 'success' && + needs.test.result == 'success' + ) + steps: + - uses: actions/checkout@v6 + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: '3.13' + + - name: Detect Python layout + id: python_layout + run: | + if [ -f pyproject.toml ]; then + PYTHON_ROOT="." + MULTI_LANGUAGE="false" + echo "root=." >> "$GITHUB_OUTPUT" + echo "multi_language=false" >> "$GITHUB_OUTPUT" + elif [ -f python/pyproject.toml ]; then + PYTHON_ROOT="python" + MULTI_LANGUAGE="true" + echo "root=python" >> "$GITHUB_OUTPUT" + echo "multi_language=true" >> "$GITHUB_OUTPUT" + else + echo "::error::Could not find pyproject.toml at repo root or python/pyproject.toml" + exit 1 + fi + + if [ "$PYTHON_ROOT" = "." ]; then + echo "dist_dir=dist" >> "$GITHUB_OUTPUT" + else + echo "dist_dir=$PYTHON_ROOT/dist" >> "$GITHUB_OUTPUT" + fi + echo "Detected Python layout: root=$PYTHON_ROOT, multi_language=$MULTI_LANGUAGE" + + - name: Install build dependencies + run: | + python -m pip install --upgrade pip + pip install build twine + + - name: Build package + run: | + cd "${{ steps.python_layout.outputs.root }}" + python -m build + + - name: Check package + run: | + cd "${{ steps.python_layout.outputs.root }}" + twine check dist/* + + - name: Upload artifacts + uses: actions/upload-artifact@v7 + with: + name: dist + path: ${{ steps.python_layout.outputs.dist_dir }} + + # === CHANGELOG CHECK - only runs on PRs with code changes === + # Docs-only PRs (./docs folder, markdown files) don't require changelog fragments + changelog: + name: Changelog Fragment Check + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-changelog + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: [detect-changes] + if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any-code-changed == 'true' + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - name: Detect Python layout + id: python_layout + run: | + if [ -f pyproject.toml ]; then + PYTHON_ROOT="." + MULTI_LANGUAGE="false" + echo "root=." >> "$GITHUB_OUTPUT" + echo "multi_language=false" >> "$GITHUB_OUTPUT" + elif [ -f python/pyproject.toml ]; then + PYTHON_ROOT="python" + MULTI_LANGUAGE="true" + echo "root=python" >> "$GITHUB_OUTPUT" + echo "multi_language=true" >> "$GITHUB_OUTPUT" + else + echo "::error::Could not find pyproject.toml at repo root or python/pyproject.toml" + exit 1 + fi + + if [ "$PYTHON_ROOT" = "." ]; then + echo "dist_dir=dist" >> "$GITHUB_OUTPUT" + else + echo "dist_dir=$PYTHON_ROOT/dist" >> "$GITHUB_OUTPUT" + fi + echo "Detected Python layout: root=$PYTHON_ROOT, multi_language=$MULTI_LANGUAGE" + + - name: Install scriv + run: pip install "scriv[toml]" + + - name: Check for changelog fragments + env: + GITHUB_BASE_REF: ${{ github.base_ref }} + run: | + set -euo pipefail + + PYTHON_ROOT="${{ steps.python_layout.outputs.root }}" + if [ "$PYTHON_ROOT" = "." ]; then + FRAGMENT_DIR="changelog.d" + SOURCE_PATTERN="^(src/|tests/|scripts/)" + else + FRAGMENT_DIR="$PYTHON_ROOT/changelog.d" + SOURCE_PATTERN="^$PYTHON_ROOT/(src/|tests/|scripts/)" + fi + + # Get list of fragment files (excluding README and template) + FRAGMENTS=$(find "$FRAGMENT_DIR" -name "*.md" ! -name "README.md" ! -name "*.j2" 2>/dev/null | wc -l) + + # Get changed files in PR + CHANGED_FILES=$(git diff --name-only "origin/${GITHUB_BASE_REF}...HEAD") + + # Check if any source files changed (excluding docs and config) + SOURCE_CHANGED=$(printf '%s\n' "$CHANGED_FILES" | grep -cE "$SOURCE_PATTERN" || true) + + if [ "$SOURCE_CHANGED" -gt 0 ] && [ "$FRAGMENTS" -eq 0 ]; then + echo "::error::No changelog fragment found. Please run 'scriv create' and document your changes." + echo "" + echo "To create a changelog fragment:" + echo " pip install 'scriv[toml]'" + echo " scriv create" + echo "" + echo "This is similar to adding a changeset in JavaScript projects." + echo "See changelog.d/README.md for more information." + exit 1 + fi + + echo "✓ Changelog check passed" + + # === DOCKER IMAGE BUILD CHECK (pull requests) === + # Build without pushing so a broken Dockerfile fails before package publish. + # Skip cleanly when a generated repository does not include a Dockerfile. + docker-build: + name: Docker Image Build Check + runs-on: ubuntu-latest + timeout-minutes: 60 + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-docker-build + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: [detect-changes] + if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any-code-changed == 'true' + steps: + - uses: actions/checkout@v6 + + - name: Detect Dockerfile + id: dockerfile + run: | + if [ -f Dockerfile ]; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + echo "::notice::Skipping Docker build because no Dockerfile is present" + fi + + - name: Set up Docker Buildx + if: steps.dockerfile.outputs.exists == 'true' + uses: docker/setup-buildx-action@v4 + + - name: Build Docker image (no push) + if: steps.dockerfile.outputs.exists == 'true' + timeout-minutes: 40 + uses: docker/build-push-action@v7 + with: + context: . + push: false + load: true + tags: app:pr-check + cache-from: type=gha + cache-to: type=gha,mode=max + + # RELEASE JOBS - Only run after all CI checks pass + + # Automatic release on push to main (if version changed) + auto-release: + name: Auto Release + needs: [lint, test, build] + outputs: + released: ${{ steps.github_release.outputs.released }} + version: ${{ steps.github_release.outputs.version }} + concurrency: + group: ${{ github.workflow }}-main-write + cancel-in-progress: false + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: write + id-token: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: '3.13' + + - name: Detect Python layout + id: python_layout + run: | + if [ -f pyproject.toml ]; then + PYTHON_ROOT="." + MULTI_LANGUAGE="false" + echo "root=." >> "$GITHUB_OUTPUT" + echo "multi_language=false" >> "$GITHUB_OUTPUT" + elif [ -f python/pyproject.toml ]; then + PYTHON_ROOT="python" + MULTI_LANGUAGE="true" + echo "root=python" >> "$GITHUB_OUTPUT" + echo "multi_language=true" >> "$GITHUB_OUTPUT" + else + echo "::error::Could not find pyproject.toml at repo root or python/pyproject.toml" + exit 1 + fi + + if [ "$PYTHON_ROOT" = "." ]; then + echo "dist_dir=dist" >> "$GITHUB_OUTPUT" + else + echo "dist_dir=$PYTHON_ROOT/dist" >> "$GITHUB_OUTPUT" + fi + echo "Detected Python layout: root=$PYTHON_ROOT, multi_language=$MULTI_LANGUAGE" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build twine + + - name: Check if version changed + id: version_check + run: | + PYTHON_ROOT="${{ steps.python_layout.outputs.root }}" + PYPROJECT="$PYTHON_ROOT/pyproject.toml" + + # Get current version from pyproject.toml + CURRENT_VERSION=$(grep -Po '(?<=^version = ")[^"]*' "$PYPROJECT") + echo "current_version=$CURRENT_VERSION" >> "$GITHUB_OUTPUT" + + if [ "${{ steps.python_layout.outputs.multi_language }}" = "true" ]; then + TAG="py_v$CURRENT_VERSION" + else + TAG="v$CURRENT_VERSION" + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + + # Check if tag exists + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "Tag $TAG already exists, skipping release" + echo "should_release=false" >> "$GITHUB_OUTPUT" + else + echo "New version detected: $CURRENT_VERSION ($TAG)" + echo "should_release=true" >> "$GITHUB_OUTPUT" + fi + + - name: Download artifacts + if: steps.version_check.outputs.should_release == 'true' + uses: actions/download-artifact@v7 + with: + name: dist + path: dist/ + + - name: Publish to PyPI + if: steps.version_check.outputs.should_release == 'true' + uses: pypa/gh-action-pypi-publish@release/v1 + + - name: Smoke test published package + if: steps.version_check.outputs.should_release == 'true' + run: | + python scripts/smoke_test_published_package.py \ + --version "${{ steps.version_check.outputs.current_version }}" + + - name: Create GitHub Release + id: github_release + if: steps.version_check.outputs.should_release == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_VERSION: ${{ steps.version_check.outputs.current_version }} + run: | + python "${{ steps.python_layout.outputs.root }}/scripts/create_github_release.py" \ + --version "$RELEASE_VERSION" \ + --repository "${{ github.repository }}" \ + --repository-root "." + echo "released=true" >> "$GITHUB_OUTPUT" + echo "version=$RELEASE_VERSION" >> "$GITHUB_OUTPUT" + + # Manual release via workflow_dispatch - only after CI passes + manual-release: + name: Manual Release + needs: [lint, test, build] + outputs: + released: ${{ steps.github_release.outputs.released }} + version: ${{ steps.github_release.outputs.version }} + concurrency: + group: ${{ github.workflow }}-main-write + cancel-in-progress: false + # !cancelled() prevents the skipped detect-changes dependency from propagating + # through lint/test/build while still propagating workflow cancellation. + # The explicit result checks ensure release only proceeds after CI passes. + if: | + !cancelled() && + github.event_name == 'workflow_dispatch' && + needs.lint.result == 'success' && + needs.test.result == 'success' && + needs.build.result == 'success' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: write + id-token: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: '3.13' + + - name: Detect Python layout + id: python_layout + run: | + if [ -f pyproject.toml ]; then + PYTHON_ROOT="." + MULTI_LANGUAGE="false" + echo "root=." >> "$GITHUB_OUTPUT" + echo "multi_language=false" >> "$GITHUB_OUTPUT" + elif [ -f python/pyproject.toml ]; then + PYTHON_ROOT="python" + MULTI_LANGUAGE="true" + echo "root=python" >> "$GITHUB_OUTPUT" + echo "multi_language=true" >> "$GITHUB_OUTPUT" + else + echo "::error::Could not find pyproject.toml at repo root or python/pyproject.toml" + exit 1 + fi + + if [ "$PYTHON_ROOT" = "." ]; then + echo "dist_dir=dist" >> "$GITHUB_OUTPUT" + else + echo "dist_dir=$PYTHON_ROOT/dist" >> "$GITHUB_OUTPUT" + fi + echo "Detected Python layout: root=$PYTHON_ROOT, multi_language=$MULTI_LANGUAGE" + + - name: Install dependencies + run: | + cd "${{ steps.python_layout.outputs.root }}" + python -m pip install --upgrade pip + pip install build twine "scriv[toml]" + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Collect changelog fragments + env: + BUMP_TYPE: ${{ github.event.inputs.bump_type }} + run: | + cd "${{ steps.python_layout.outputs.root }}" + # Check if there are any fragments to collect + FRAGMENTS=$(find changelog.d -name "*.md" ! -name "README.md" ! -name "*.j2" 2>/dev/null | wc -l) + if [ "$FRAGMENTS" -gt 0 ]; then + echo "Found $FRAGMENTS changelog fragment(s), collecting..." + scriv collect --version "$BUMP_TYPE" + else + echo "No changelog fragments found, skipping collection" + fi + + - name: Version and commit + id: version + env: + BUMP_TYPE: ${{ github.event.inputs.bump_type }} + DESCRIPTION: ${{ github.event.inputs.description }} + run: | + cd "${{ steps.python_layout.outputs.root }}" + python scripts/version_and_commit.py \ + --bump-type "$BUMP_TYPE" \ + --description "$DESCRIPTION" + + - name: Build package + if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' + run: | + cd "${{ steps.python_layout.outputs.root }}" + python -m build + + - name: Check package + if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' + run: | + cd "${{ steps.python_layout.outputs.root }}" + twine check dist/* + + - name: Publish to PyPI + if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: ${{ steps.python_layout.outputs.dist_dir }} + + - name: Smoke test published package + if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' + run: | + python scripts/smoke_test_published_package.py \ + --version "${{ steps.version.outputs.new_version }}" + + - name: Create GitHub Release + id: github_release + if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_VERSION: ${{ steps.version.outputs.new_version }} + run: | + python "${{ steps.python_layout.outputs.root }}/scripts/create_github_release.py" \ + --version "$RELEASE_VERSION" \ + --repository "${{ github.repository }}" \ + --repository-root "." + echo "released=true" >> "$GITHUB_OUTPUT" + echo "version=$RELEASE_VERSION" >> "$GITHUB_OUTPUT" + + # Optional Docker Hub publishing after the GitHub release exists. Set the + # DOCKERHUB_IMAGE and DOCKERHUB_USERNAME repository variables and the + # DOCKERHUB_TOKEN secret to enable it in repositories that ship a Dockerfile. + docker-publish-config: + name: Configure Docker Hub Publish + needs: [auto-release, manual-release] + if: | + !cancelled() && ( + needs.auto-release.outputs.released == 'true' || + needs.manual-release.outputs.released == 'true' + ) + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + outputs: + enabled: ${{ steps.config.outputs.enabled }} + image: ${{ steps.config.outputs.image }} + version: ${{ steps.config.outputs.version }} + env: + DOCKERHUB_IMAGE: ${{ vars.DOCKERHUB_IMAGE }} + DOCKERHUB_USERNAME: ${{ vars.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + RELEASE_VERSION: ${{ needs.auto-release.outputs.version || needs.manual-release.outputs.version }} + steps: + - uses: actions/checkout@v6 + + - name: Check Docker publish configuration + id: config + run: | + if [ ! -f Dockerfile ]; then + echo "::notice::Skipping Docker publish because no Dockerfile is present" + echo "enabled=false" >> "$GITHUB_OUTPUT" + elif [ -z "$DOCKERHUB_IMAGE" ] || [ -z "$DOCKERHUB_USERNAME" ] || [ -z "$DOCKERHUB_TOKEN" ]; then + echo "::notice::Skipping Docker publish because Docker Hub is not configured" + echo "enabled=false" >> "$GITHUB_OUTPUT" + else + { + echo "enabled=true" + echo "image=$DOCKERHUB_IMAGE" + echo "version=$RELEASE_VERSION" + } >> "$GITHUB_OUTPUT" + fi + + # Native runners build both architectures in parallel and push immutable + # digests. This avoids the performance and reliability cost of QEMU. + docker-publish-build: + name: Build Docker Image (${{ matrix.platform }}) + needs: [docker-publish-config] + if: needs.docker-publish-config.outputs.enabled == 'true' + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push platform image by digest + id: docker_build + timeout-minutes: 40 + uses: docker/build-push-action@v7 + with: + context: . + platforms: ${{ matrix.platform }} + cache-from: type=gha,scope=${{ matrix.platform }} + cache-to: type=gha,mode=max,scope=${{ matrix.platform }} + outputs: type=image,name=${{ needs.docker-publish-config.outputs.image }},push-by-digest=true,name-canonical=true,push=true + + - name: Export image digest + env: + DIGEST: ${{ steps.docker_build.outputs.digest }} + run: | + mkdir -p /tmp/digests + touch "/tmp/digests/${DIGEST#sha256:}" + + - name: Upload image digest + uses: actions/upload-artifact@v7 + with: + name: docker-digest-${{ strategy.job-index }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + docker-publish: + name: Publish Multi-Architecture Docker Image + needs: [docker-publish-config, docker-publish-build] + if: needs.docker-publish-build.result == 'success' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Download image digests + uses: actions/download-artifact@v7 + with: + path: /tmp/digests + pattern: docker-digest-* + merge-multiple: true + + - name: Create multi-architecture manifest + working-directory: /tmp/digests + env: + IMAGE: ${{ needs.docker-publish-config.outputs.image }} + VERSION: ${{ needs.docker-publish-config.outputs.version }} + run: | + # Word splitting is deliberate: every digest file in this directory + # becomes its own source argument for the manifest. + # shellcheck disable=SC2046 + docker buildx imagetools create \ + --tag "${IMAGE}:latest" \ + --tag "${IMAGE}:${VERSION}" \ + $(printf "${IMAGE}@sha256:%s " *) + + - name: Verify multi-architecture manifest + env: + IMAGE: ${{ needs.docker-publish-config.outputs.image }} + VERSION: ${{ needs.docker-publish-config.outputs.version }} + run: | + MANIFEST=$(docker buildx imagetools inspect "${IMAGE}:${VERSION}") + grep -q "linux/amd64" <<< "$MANIFEST" + grep -q "linux/arm64" <<< "$MANIFEST" + + # A job killed by timeout-minutes concludes "cancelled", not "failure". + # Observe every other job so that state cannot silently hide a broken run. + pipeline-status: + name: Pipeline Status + runs-on: ubuntu-latest + timeout-minutes: 5 + if: always() + needs: [detect-changes, lint, test, build, changelog, docker-build, + auto-release, manual-release, docker-publish-config, + docker-publish-build, docker-publish] + steps: + - uses: actions/checkout@v6 + + - name: Fail the run when a required job was cancelled or failed + env: + NEEDS_JSON: ${{ toJSON(needs) }} + IS_MAIN: ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' }} + run: bash scripts/check-pipeline-status.sh diff --git a/dev/log/issues/298/pulls/299/templates/python/security.yml b/dev/log/issues/298/pulls/299/templates/python/security.yml new file mode 100644 index 00000000..a252a4a1 --- /dev/null +++ b/dev/log/issues/298/pulls/299/templates/python/security.yml @@ -0,0 +1,75 @@ +name: Security + +on: + push: + branches: [main] + pull_request: + schedule: + - cron: '0 6 * * 1' + +permissions: + contents: read + +jobs: + dependency-audit: + name: Audit Resolved Python Dependencies + runs-on: ubuntu-latest + timeout-minutes: 15 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-dependency-audit + cancel-in-progress: true + steps: + - uses: actions/checkout@v6 + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: '3.13' + + - name: Audit pyproject.toml and docs/requirements.txt + run: python scripts/audit_dependencies.py + + codeql: + name: CodeQL (${{ matrix.language }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-codeql-${{ matrix.language }} + cancel-in-progress: true + permissions: + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + language: [python, actions] + steps: + - uses: actions/checkout@v6 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + + - name: Autobuild + uses: github/codeql-action/autobuild@v4 + + - name: Analyze + uses: github/codeql-action/analyze@v4 + + dependency-review: + name: Dependency Review + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v6 + + - name: Review dependency changes + uses: actions/dependency-review-action@v5 + with: + fail-on-severity: high + comment-summary-in-pr: on-failure diff --git a/dev/log/issues/298/pulls/299/templates/python/workflows.yml b/dev/log/issues/298/pulls/299/templates/python/workflows.yml new file mode 100644 index 00000000..56819a4d --- /dev/null +++ b/dev/log/issues/298/pulls/299/templates/python/workflows.yml @@ -0,0 +1,42 @@ +name: Workflows + +# GitHub Actions ignores unknown workflow keys instead of rejecting them, so a +# typo such as `queue: max` inside a `concurrency:` block silently does nothing +# (issue #62). actionlint validates the schema and, through shellcheck, every +# `run:` block as well. +on: + push: + branches: + - main + paths: + - '.github/**' + pull_request: + types: [opened, synchronize, reopened] + paths: + - '.github/**' + workflow_dispatch: + +permissions: + contents: read + +jobs: + actionlint: + name: Lint Workflows + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-actionlint + cancel-in-progress: true + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + + # The Docker image bundles shellcheck and pyflakes, so this lints every + # `run:` block too. A native actionlint binary without shellcheck on + # PATH silently skips the shell checks and still exits 0 -- worth knowing + # before trying to reproduce a finding locally. + - name: Lint workflow files + uses: docker://rhysd/actionlint:1.7.7 + with: + args: -color diff --git a/dev/log/issues/298/pulls/299/templates/rust-publish-crate.rs b/dev/log/issues/298/pulls/299/templates/rust-publish-crate.rs new file mode 100644 index 00000000..bb61d1e6 --- /dev/null +++ b/dev/log/issues/298/pulls/299/templates/rust-publish-crate.rs @@ -0,0 +1,338 @@ +#!/usr/bin/env rust-script +//! Publish package to crates.io +//! +//! This script publishes the Rust package to crates.io and handles +//! the case where the version already exists. +//! +//! Supports both single-language and multi-language repository structures: +//! - Single-language: Cargo.toml in repository root +//! - Multi-language: Cargo.toml in rust/ subfolder +//! +//! Usage: rust-script scripts/publish-crate.rs [--token ] [--rust-root ] +//! +//! Environment variables (checked in order of priority): +//! - CARGO_REGISTRY_TOKEN: Cargo's native crates.io token (preferred) +//! - CARGO_TOKEN: Alternative token name for backwards compatibility +//! +//! Outputs (written to GITHUB_OUTPUT): +//! - publish_result: one of +//! 'success' - the crate version was published to crates.io +//! 'already_exists' - the version is already on crates.io (version-bump bug) +//! 'auth_failed' - missing or invalid crates.io authentication token +//! 'rate_limited' - crates.io returned HTTP 429 (too many versions in 24h); +//! deferred, automatically-recoverable outcome — the +//! script exits 0 and the same version is retried on the +//! next push to 'main' once the throttle window clears +//! 'skipped' - publish skipped (e.g. template default package name) +//! 'failed' - publish failed for an unrecognised reason +//! +//! ```cargo +//! [dependencies] +//! regex = "1" +//! ``` + +use std::env; +use std::fs; +use std::io::Write; +use std::process::{Command, exit}; + +#[path = "rust-paths.rs"] +mod rust_paths; + +fn get_arg(name: &str) -> Option { + let args: Vec = env::args().collect(); + let flag = format!("--{}", name); + + if let Some(idx) = args.iter().position(|a| a == &flag) { + return args.get(idx + 1).cloned(); + } + + None +} + +fn needs_cd(rust_root: &str) -> bool { + rust_root != "." +} + +fn set_output(key: &str, value: &str) { + if let Ok(output_file) = env::var("GITHUB_OUTPUT") { + if let Ok(mut file) = fs::OpenOptions::new().create(true).append(true).open(&output_file) { + let _ = writeln!(file, "{}={}", key, value); + } + } + println!("Output: {}={}", key, value); +} + +/// Classification of a failed `cargo publish` attempt. +/// +/// Every failure branch funnels through this single enum so the `publish_result` +/// output value and the catch-all behaviour cannot drift apart over time. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FailureKind { + AlreadyExists, + AuthFailed, + RateLimited, + Unknown, +} + +impl FailureKind { + fn output_value(self) -> &'static str { + match self { + FailureKind::AlreadyExists => "already_exists", + FailureKind::AuthFailed => "auth_failed", + FailureKind::RateLimited => "rate_limited", + FailureKind::Unknown => "failed", + } + } + + /// Whether this failure is a *deferred*, automatically-recoverable outcome + /// rather than a hard error. + /// + /// A crates.io HTTP 429 throttle is transient: the same version is re-tried + /// on the next push to `main` once the 24-hour window clears (see + /// `scripts/check-release-needed.rs`). The script therefore exits + /// successfully for this case so a recoverable throttle does not turn the + /// whole release job red. Every other failure stays non-zero. + fn is_deferred(self) -> bool { + matches!(self, FailureKind::RateLimited) + } +} + +/// Classify a combined stdout/stderr blob from `cargo publish` into a +/// [`FailureKind`]. +/// +/// Note: the rate-limit checks come before the auth checks because a crates.io +/// 429 body never contains the auth-token markers, while the rate-limit markers +/// are unambiguous. +fn classify_failure(combined: &str) -> FailureKind { + if combined.contains("already uploaded") || combined.contains("already exists") { + FailureKind::AlreadyExists + } else if combined.contains("429 Too Many Requests") + || combined.contains("Too Many Requests") + || combined.contains("too many versions") + || combined.contains("too many requests") + { + FailureKind::RateLimited + } else if combined.contains("non-empty token") + || combined.contains("please provide a") + || combined.contains("unauthorized") + || combined.contains("authentication") + { + FailureKind::AuthFailed + } else { + FailureKind::Unknown + } +} + +fn main() { + let rust_root = match rust_paths::get_rust_root(None, true) { + Ok(root) => root, + Err(e) => { + eprintln!("Error: {}", e); + exit(1); + } + }; + let cargo_toml = rust_paths::get_cargo_toml_path(&rust_root); + let package_manifest = match rust_paths::get_package_manifest_path(&cargo_toml) { + Ok(path) => path, + Err(e) => { + eprintln!("Error: {}", e); + exit(1); + } + }; + + // Get token from CLI arg, then env vars + let token = get_arg("token") + .or_else(|| env::var("CARGO_REGISTRY_TOKEN").ok().filter(|s| !s.is_empty())) + .or_else(|| env::var("CARGO_TOKEN").ok().filter(|s| !s.is_empty())); + + let package_info = match rust_paths::read_package_info(&package_manifest) { + Ok(info) => info, + Err(e) => { + eprintln!("Error: {}", e); + exit(1); + } + }; + let name = package_info.name; + let version = package_info.version; + + println!("Package: {}@{}", name, version); + + if name == "example-sum-package-name" { + println!("Skipping publish: package name is the template default 'example-sum-package-name'"); + println!("Rename the package in Cargo.toml before publishing to crates.io"); + set_output("publish_result", "skipped"); + return; + } + + println!(); + println!("=== Attempting to publish to crates.io ==="); + + if token.is_none() { + println!("::warning::Neither CARGO_REGISTRY_TOKEN nor CARGO_TOKEN is set, attempting publish without explicit token"); + println!(); + println!("To fix this, ensure one of the following secrets is configured:"); + println!(" - CARGO_REGISTRY_TOKEN (Cargo's native env var, preferred)"); + println!(" - CARGO_TOKEN (alternative for backwards compatibility)"); + println!(); + println!("For organization secrets, you may need to map the secret name in your workflow:"); + println!(" env:"); + println!(" CARGO_REGISTRY_TOKEN: ${{{{ secrets.CARGO_TOKEN }}}}"); + println!(); + } else { + println!("Using provided authentication token"); + } + + // Build the cargo publish command + let mut cmd = Command::new("cargo"); + cmd.arg("publish").arg("--allow-dirty").arg("-p").arg(&name); + + if let Some(t) = &token { + cmd.arg("--token").arg(t); + } + + // For multi-language repos, change to the rust directory + if needs_cd(&rust_root) { + cmd.current_dir(&rust_root); + } + + let output = cmd.output().expect("Failed to execute cargo publish"); + + if output.status.success() { + println!("Successfully published {}@{} to crates.io", name, version); + set_output("publish_result", "success"); + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let combined = format!("{}\n{}", stdout, stderr); + + let kind = classify_failure(&combined); + match kind { + FailureKind::AlreadyExists => { + eprintln!(); + eprintln!("=== VERSION ALREADY PUBLISHED ==="); + eprintln!(); + eprintln!("Version {} already exists on crates.io.", version); + eprintln!("The release pipeline must always publish a version greater than what is already published."); + eprintln!("This indicates a bug in version bumping: the pipeline should have computed a new, unpublished version."); + eprintln!(); + } + FailureKind::RateLimited => { + eprintln!(); + eprintln!("=== CRATES.IO RATE LIMIT (HTTP 429) ==="); + eprintln!(); + eprintln!("crates.io rejected the publish because too many versions of this"); + eprintln!("crate have been published in the last 24 hours."); + eprintln!(); + eprintln!("Original cargo publish error:"); + eprintln!("{}", combined.trim()); + eprintln!(); + eprintln!("This is a TRANSIENT, automatically-recoverable throttle, not a pipeline bug."); + eprintln!("No action is required other than waiting for the 24-hour window to roll over."); + eprintln!("scripts/check-release-needed.rs will re-attempt the same version on the next"); + eprintln!("push to 'main' once the throttle window has cleared."); + eprintln!(); + eprintln!("See: https://doc.rust-lang.org/cargo/reference/publishing.html#publishing-a-new-version-of-an-existing-crate"); + eprintln!(); + } + FailureKind::AuthFailed => { + eprintln!(); + eprintln!("=== AUTHENTICATION FAILURE ==="); + eprintln!(); + eprintln!("Failed to publish due to missing or invalid authentication token."); + eprintln!(); + eprintln!("SOLUTION: Configure one of these secrets in your repository or organization:"); + eprintln!(" 1. CARGO_REGISTRY_TOKEN - Cargo's native environment variable (preferred)"); + eprintln!(" 2. CARGO_TOKEN - Alternative name for backwards compatibility"); + eprintln!(); + eprintln!("If using organization secrets with a different name, map it in your workflow:"); + eprintln!(" - name: Publish to Crates.io"); + eprintln!(" env:"); + eprintln!(" CARGO_REGISTRY_TOKEN: ${{{{ secrets.YOUR_SECRET_NAME }}}}"); + eprintln!(); + eprintln!("See: https://doc.rust-lang.org/cargo/reference/publishing.html"); + eprintln!(); + } + FailureKind::Unknown => { + eprintln!("Failed to publish for unknown reason"); + eprintln!("{}", combined); + } + } + + set_output("publish_result", kind.output_value()); + + // A rate-limit is a deferred, automatically-recoverable outcome: exit + // successfully so the release job does not go red over a transient + // crates.io throttle. Downstream release-artifact steps are gated on a + // successful publish (see .github/workflows/release.yml), so a deferred + // upload never produces partial Docker/GitHub release artifacts. + if kind.is_deferred() { + return; + } + + exit(1); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classifies_already_exists_response() { + let body = "error: crate version 0.1.0 is already uploaded"; + assert_eq!(classify_failure(body), FailureKind::AlreadyExists); + assert_eq!(FailureKind::AlreadyExists.output_value(), "already_exists"); + } + + #[test] + fn classifies_rate_limit_response() { + let body = "\ +error: failed to publish my-crate v0.42.0 to registry at https://crates.io + +Caused by: + the remote server responded with an error (status 429 Too Many Requests): \ +You have published too many versions of this crate in the last 24 hours +"; + assert_eq!(classify_failure(body), FailureKind::RateLimited); + assert_eq!(FailureKind::RateLimited.output_value(), "rate_limited"); + } + + #[test] + fn classifies_rate_limit_from_too_many_versions_marker() { + let body = "the remote server responded: You have published too many versions"; + assert_eq!(classify_failure(body), FailureKind::RateLimited); + } + + #[test] + fn classifies_auth_failure_response() { + let body = "error: failed to publish: please provide a non-empty token"; + assert_eq!(classify_failure(body), FailureKind::AuthFailed); + assert_eq!(FailureKind::AuthFailed.output_value(), "auth_failed"); + } + + #[test] + fn classifies_unknown_response() { + let body = "error: some brand new failure mode nobody has seen before"; + assert_eq!(classify_failure(body), FailureKind::Unknown); + assert_eq!(FailureKind::Unknown.output_value(), "failed"); + } + + #[test] + fn rate_limit_takes_precedence_over_auth_markers() { + // A 429 body that also happens to mention "authentication" must still be + // classified as rate-limited, since the throttle is the actionable cause. + let body = "status 429 Too Many Requests: authentication retry later"; + assert_eq!(classify_failure(body), FailureKind::RateLimited); + } + + #[test] + fn only_rate_limit_is_deferred() { + // A rate-limit is the single deferred (exit-0) outcome; every other + // failure must remain a hard, non-zero error. + assert!(FailureKind::RateLimited.is_deferred()); + assert!(!FailureKind::AlreadyExists.is_deferred()); + assert!(!FailureKind::AuthFailed.is_deferred()); + assert!(!FailureKind::Unknown.is_deferred()); + } +} diff --git a/dev/log/issues/298/pulls/299/templates/rust-smoke-test-published-crate.rs b/dev/log/issues/298/pulls/299/templates/rust-smoke-test-published-crate.rs new file mode 100644 index 00000000..cf38805d --- /dev/null +++ b/dev/log/issues/298/pulls/299/templates/rust-smoke-test-published-crate.rs @@ -0,0 +1,578 @@ +#!/usr/bin/env rust-script +//! Install-from-package smoke test for a published crates.io artifact. +//! +//! This script proves that the freshly published crate is usable by downstream +//! consumers, not just visible in the crates.io index: +//! - installs advertised binary targets with `cargo install` into a temp root +//! - runs each installed binary with `--help` +//! - compiles a fresh dependent crate that imports the published library target +//! +//! CLI output is captured before previewing a few lines, so the smoke test never +//! pipes a live Rust process into a short reader such as `head` under `pipefail`. +//! +//! Usage: +//! rust-script scripts/smoke-test-published-crate.rs --release-version +//! +//! Optional arguments: +//! --crate-name Crate name. Defaults to Cargo.toml package name. +//! --rust-root Root containing Cargo.toml. Defaults to auto-detect. +//! --max-attempts Defaults to 5. +//! --sleep-seconds Defaults to 10. +//! +//! Outputs (written to GITHUB_OUTPUT): +//! - smoke_test: 'pass', or 'skipped' for template defaults +//! +//! ```cargo +//! [dependencies] +//! regex = "1" +//! ``` + +use regex::Regex; +use std::env; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{exit, Command, Output}; +use std::thread; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +#[path = "rust-paths.rs"] +mod rust_paths; + +const TEMPLATE_DEFAULT_CRATE: &str = "example-sum-package-name"; +const DEFAULT_MAX_ATTEMPTS: u64 = 5; +const DEFAULT_SLEEP_SECONDS: u64 = 10; +const CLI_PREVIEW_LINES: usize = 20; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct EntryPoints { + crate_name: String, + version: String, + lib_name: Option, + bin_names: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ManifestSection { + Lib, + Bin, + Other, +} + +fn get_arg(name: &str) -> Option { + let args: Vec = env::args().collect(); + let flag = format!("--{name}"); + + if let Some(idx) = args.iter().position(|a| a == &flag) { + return args.get(idx + 1).cloned(); + } + + let env_name = name.to_uppercase().replace('-', "_"); + env::var(&env_name).ok().filter(|s| !s.is_empty()) +} + +fn parse_count_arg(name: &str, default: u64) -> u64 { + get_arg(name) + .and_then(|value| { + value.parse::().map_or_else( + |_| { + eprintln!("Warning: Invalid {name} value '{value}'; using default {default}"); + None + }, + Some, + ) + }) + .unwrap_or(default) +} + +fn set_output(key: &str, value: &str) { + if let Ok(output_file) = env::var("GITHUB_OUTPUT") { + if let Err(e) = fs::OpenOptions::new() + .create(true) + .append(true) + .open(&output_file) + .and_then(|mut f| writeln!(f, "{key}={value}")) + { + eprintln!("Warning: Could not write to GITHUB_OUTPUT: {e}"); + } + } + println!("Output: {key}={value}"); +} + +fn should_skip_smoke_test(crate_name: &str) -> bool { + crate_name == TEMPLATE_DEFAULT_CRATE +} + +fn default_lib_name(package_name: &str) -> String { + package_name.replace('-', "_") +} + +fn manifest_value(line: &str, key: &str) -> Option { + let re = Regex::new(&format!(r#"^\s*{}\s*=\s*"([^"]+)""#, regex::escape(key))).unwrap(); + re.captures(line) + .and_then(|caps| caps.get(1).map(|value| value.as_str().to_string())) +} + +fn detect_entrypoints( + manifest_path: &Path, + manifest_content: &str, + crate_name: String, + version: String, +) -> EntryPoints { + let manifest_dir = manifest_path.parent().unwrap_or_else(|| Path::new(".")); + let mut section = ManifestSection::Other; + let mut explicit_lib_name = None; + let mut bin_names = Vec::new(); + + for line in manifest_content.lines() { + let trimmed = line.trim(); + + if trimmed == "[lib]" { + section = ManifestSection::Lib; + continue; + } + if trimmed == "[[bin]]" { + section = ManifestSection::Bin; + continue; + } + if trimmed.starts_with('[') { + section = ManifestSection::Other; + continue; + } + + let Some(name) = manifest_value(line, "name") else { + continue; + }; + match section { + ManifestSection::Lib => explicit_lib_name = Some(name), + ManifestSection::Bin => bin_names.push(name), + ManifestSection::Other => {} + } + } + + let has_library = explicit_lib_name.is_some() || manifest_dir.join("src/lib.rs").exists(); + let lib_name = + has_library.then(|| explicit_lib_name.unwrap_or_else(|| default_lib_name(&crate_name))); + + if bin_names.is_empty() && manifest_dir.join("src/main.rs").exists() { + bin_names.push(crate_name.clone()); + } + + EntryPoints { + crate_name, + version, + lib_name, + bin_names, + } +} + +fn read_entrypoints( + package_manifest: &Path, + crate_name_override: Option, +) -> Result { + let package_info = rust_paths::read_package_info(package_manifest)?; + let manifest_content = fs::read_to_string(package_manifest) + .map_err(|e| format!("Failed to read {}: {e}", package_manifest.display()))?; + let crate_name = crate_name_override.unwrap_or(package_info.name); + let version = get_arg("release-version").unwrap_or(package_info.version); + + Ok(detect_entrypoints( + package_manifest, + &manifest_content, + crate_name, + version, + )) +} + +fn output_text(output: &Output) -> String { + let mut text = String::new(); + text.push_str(&String::from_utf8_lossy(&output.stdout)); + if !output.stderr.is_empty() { + if !text.is_empty() { + text.push('\n'); + } + text.push_str(&String::from_utf8_lossy(&output.stderr)); + } + text +} + +fn preview_lines(text: &str, max_lines: usize) -> String { + text.lines().take(max_lines).collect::>().join("\n") +} + +fn run_status(command: &mut Command, label: &str) -> Result<(), String> { + println!("Running: {command:?}"); + let status = command + .status() + .map_err(|e| format!("Failed to start {label}: {e}"))?; + + if status.success() { + Ok(()) + } else { + Err(format!("{label} failed with status {status}")) + } +} + +fn retry( + label: &str, + max_attempts: u64, + sleep_seconds: u64, + mut attempt: F, +) -> Result<(), String> +where + F: FnMut() -> Result<(), String>, +{ + for attempt_number in 1..=max_attempts { + println!("{label} (attempt {attempt_number}/{max_attempts})"); + match attempt() { + Ok(()) => return Ok(()), + Err(e) if attempt_number < max_attempts => { + eprintln!("{label} failed: {e}"); + eprintln!("Waiting {sleep_seconds}s before retrying..."); + thread::sleep(Duration::from_secs(sleep_seconds)); + } + Err(e) => return Err(format!("{label} failed after {max_attempts} attempts: {e}")), + } + } + + unreachable!("retry loop always returns"); +} + +fn make_temp_dir(crate_name: &str) -> Result { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| format!("System clock is before UNIX_EPOCH: {e}"))? + .as_nanos(); + let dir = env::temp_dir().join(format!("published-crate-smoke-{crate_name}-{nanos}")); + fs::create_dir_all(&dir).map_err(|e| format!("Failed to create {}: {e}", dir.display()))?; + Ok(dir) +} + +fn install_binaries( + entrypoints: &EntryPoints, + work_dir: &Path, + max_attempts: u64, + sleep_seconds: u64, +) -> Result, String> { + if entrypoints.bin_names.is_empty() { + println!("No binary targets detected; skipping cargo install smoke test"); + return Ok(None); + } + + let install_root = work_dir.join("install"); + let exact_version = format!("={}", entrypoints.version); + let label = format!( + "Install {}@{} from crates.io", + entrypoints.crate_name, entrypoints.version + ); + + retry(&label, max_attempts, sleep_seconds, || { + let mut command = Command::new("cargo"); + command + .arg("install") + .arg(&entrypoints.crate_name) + .arg("--version") + .arg(&exact_version) + .arg("--root") + .arg(&install_root) + .arg("--locked"); + run_status(&mut command, &label) + })?; + + Ok(Some(install_root)) +} + +fn installed_binary_path(install_root: &Path, bin_name: &str) -> PathBuf { + install_root + .join("bin") + .join(format!("{bin_name}{}", env::consts::EXE_SUFFIX)) +} + +fn check_cli_entrypoints( + entrypoints: &EntryPoints, + install_root: Option<&Path>, +) -> Result<(), String> { + let Some(install_root) = install_root else { + return Ok(()); + }; + + for bin_name in &entrypoints.bin_names { + let bin_path = installed_binary_path(install_root, bin_name); + if !bin_path.exists() { + return Err(format!( + "Expected installed binary {} was not found", + bin_path.display() + )); + } + + println!("Checking CLI entry point: {} --help", bin_path.display()); + let output = Command::new(&bin_path) + .arg("--help") + .output() + .map_err(|e| format!("Failed to run {} --help: {e}", bin_path.display()))?; + let combined = output_text(&output); + + if !output.status.success() { + return Err(format!( + "{} --help failed with status {}\n{}", + bin_path.display(), + output.status, + combined.trim() + )); + } + if combined.trim().is_empty() { + return Err(format!("{} --help produced no output", bin_path.display())); + } + + println!("CLI OK: {bin_name} --help produced output"); + println!("{}", preview_lines(&combined, CLI_PREVIEW_LINES)); + } + + Ok(()) +} + +fn toml_string(value: &str) -> String { + value.replace('\\', "\\\\").replace('"', "\\\"") +} + +fn library_consumer_manifest(lib_name: &str, crate_name: &str, version: &str) -> String { + format!( + r#"[package] +name = "published-crate-smoke-test" +version = "0.0.0" +edition = "2021" +publish = false + +[dependencies] +"{}" = {{ package = "{}", version = "={}" }} +"#, + toml_string(lib_name), + toml_string(crate_name), + toml_string(version) + ) +} + +fn library_consumer_main(lib_name: &str) -> String { + format!( + r#"extern crate {lib_name}; + +fn main() {{ + println!("library OK: {lib_name} is importable"); +}} +"# + ) +} + +fn check_library_entrypoint( + entrypoints: &EntryPoints, + work_dir: &Path, + max_attempts: u64, + sleep_seconds: u64, +) -> Result<(), String> { + let Some(lib_name) = &entrypoints.lib_name else { + println!("No library target detected; skipping dependent-crate smoke test"); + return Ok(()); + }; + + println!("Checking library entry point: dependent crate imports {lib_name}"); + let consumer_dir = work_dir.join("lib-consumer"); + let src_dir = consumer_dir.join("src"); + fs::create_dir_all(&src_dir) + .map_err(|e| format!("Failed to create {}: {e}", src_dir.display()))?; + fs::write( + consumer_dir.join("Cargo.toml"), + library_consumer_manifest(lib_name, &entrypoints.crate_name, &entrypoints.version), + ) + .map_err(|e| format!("Failed to write dependent Cargo.toml: {e}"))?; + fs::write(src_dir.join("main.rs"), library_consumer_main(lib_name)) + .map_err(|e| format!("Failed to write dependent main.rs: {e}"))?; + + let label = format!( + "Compile dependent crate against {}@{}", + entrypoints.crate_name, entrypoints.version + ); + retry(&label, max_attempts, sleep_seconds, || { + let mut command = Command::new("cargo"); + command.arg("run").arg("--quiet").current_dir(&consumer_dir); + run_status(&mut command, &label) + }) +} + +fn run_smoke_test( + entrypoints: &EntryPoints, + max_attempts: u64, + sleep_seconds: u64, +) -> Result<(), String> { + println!( + "Smoke-testing published crate {}@{}", + entrypoints.crate_name, entrypoints.version + ); + println!( + "Detected entry points: library={:?}, binaries={:?}", + entrypoints.lib_name, entrypoints.bin_names + ); + + let work_dir = make_temp_dir(&entrypoints.crate_name)?; + println!("Workspace: {}", work_dir.display()); + + let result = (|| { + let install_root = install_binaries(entrypoints, &work_dir, max_attempts, sleep_seconds)?; + check_cli_entrypoints(entrypoints, install_root.as_deref())?; + check_library_entrypoint(entrypoints, &work_dir, max_attempts, sleep_seconds) + })(); + + if let Err(e) = fs::remove_dir_all(&work_dir) { + eprintln!("Warning: Could not remove {}: {e}", work_dir.display()); + } + + result +} + +fn main() { + let rust_root = match rust_paths::get_rust_root(None, true) { + Ok(root) => root, + Err(e) => { + eprintln!("Error: {e}"); + exit(1); + } + }; + let cargo_toml = rust_paths::get_cargo_toml_path(&rust_root); + let package_manifest = match rust_paths::get_package_manifest_path(&cargo_toml) { + Ok(path) => path, + Err(e) => { + eprintln!("Error: {e}"); + exit(1); + } + }; + let crate_name_override = get_arg("crate-name"); + let max_attempts = parse_count_arg("max-attempts", DEFAULT_MAX_ATTEMPTS); + let sleep_seconds = parse_count_arg("sleep-seconds", DEFAULT_SLEEP_SECONDS); + + let entrypoints = match read_entrypoints(&package_manifest, crate_name_override) { + Ok(entrypoints) => entrypoints, + Err(e) => { + eprintln!("Error: {e}"); + exit(1); + } + }; + + if should_skip_smoke_test(&entrypoints.crate_name) { + println!( + "Skipping published-crate smoke test: package name is the template default '{}'", + entrypoints.crate_name + ); + set_output("smoke_test", "skipped"); + return; + } + + if let Err(e) = run_smoke_test(&entrypoints, max_attempts, sleep_seconds) { + eprintln!("::error::Published crate smoke test failed: {e}"); + set_output("smoke_test", "fail"); + exit(1); + } + + println!( + "Published crate smoke test passed for {}@{}", + entrypoints.crate_name, entrypoints.version + ); + set_output("smoke_test", "pass"); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_manifest_dir(name: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = env::temp_dir().join(format!("{name}-{nanos}")); + fs::create_dir_all(dir.join("src")).unwrap(); + dir + } + + #[test] + fn detects_default_library_and_binary_targets() { + let dir = temp_manifest_dir("smoke-default-targets"); + fs::write(dir.join("src/lib.rs"), "").unwrap(); + fs::write(dir.join("src/main.rs"), "").unwrap(); + let manifest = dir.join("Cargo.toml"); + fs::write( + &manifest, + r#"[package] +name = "demo-crate" +version = "1.2.3" +edition = "2021" +"#, + ) + .unwrap(); + + let entrypoints = detect_entrypoints( + &manifest, + &fs::read_to_string(&manifest).unwrap(), + "demo-crate".to_string(), + "1.2.3".to_string(), + ); + + assert_eq!(entrypoints.lib_name.as_deref(), Some("demo_crate")); + assert_eq!(entrypoints.bin_names, vec!["demo-crate"]); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn detects_explicit_library_and_binary_target_names() { + let dir = temp_manifest_dir("smoke-explicit-targets"); + let manifest = dir.join("Cargo.toml"); + let content = r#"[package] +name = "published-name" +version = "0.5.0" +edition = "2021" + +[lib] +name = "import_name" +path = "src/lib.rs" + +[[bin]] +name = "first-cli" +path = "src/main.rs" + +[[bin]] +name = "second-cli" +path = "src/second.rs" +"#; + fs::write(&manifest, content).unwrap(); + + let entrypoints = detect_entrypoints( + &manifest, + content, + "published-name".to_string(), + "0.5.0".to_string(), + ); + + assert_eq!(entrypoints.lib_name.as_deref(), Some("import_name")); + assert_eq!(entrypoints.bin_names, vec!["first-cli", "second-cli"]); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn library_consumer_uses_exact_published_package_version() { + let manifest = library_consumer_manifest("import_name", "published-name", "2.0.1"); + + assert!(manifest + .contains(r#""import_name" = { package = "published-name", version = "=2.0.1" }"#)); + } + + #[test] + fn cli_preview_is_taken_from_captured_output() { + let output = "line 1\nline 2\nline 3\n"; + + assert_eq!(preview_lines(output, 2), "line 1\nline 2"); + } + + #[test] + fn skips_template_default_package_name() { + assert!(should_skip_smoke_test("example-sum-package-name")); + assert!(!should_skip_smoke_test("real-crate")); + } +} diff --git a/dev/log/issues/298/pulls/299/templates/rust-wait-for-crate.rs b/dev/log/issues/298/pulls/299/templates/rust-wait-for-crate.rs new file mode 100644 index 00000000..ee80cca9 --- /dev/null +++ b/dev/log/issues/298/pulls/299/templates/rust-wait-for-crate.rs @@ -0,0 +1,178 @@ +#!/usr/bin/env rust-script +//! Wait for a crates.io package version to become visible. +//! +//! The Docker release step runs after `cargo publish`, but crates.io indexing can +//! lag briefly. Waiting here makes Docker Hub tags and GitHub releases point at a +//! crate version that users can already resolve. +//! +//! Usage: +//! rust-script scripts/wait-for-crate.rs --release-version +//! +//! Optional arguments: +//! --crate-name Crate name. Defaults to Cargo.toml package name. +//! --rust-root Root containing Cargo.toml. Defaults to auto-detect. +//! --max-attempts Defaults to 30. +//! --sleep-seconds Defaults to 10. +//! +//! Outputs (written to GITHUB_OUTPUT): +//! - crate_available: 'true' when the version is visible, or 'skipped' for template defaults +//! +//! ```cargo +//! [dependencies] +//! regex = "1" +//! ureq = "2" +//! ``` + +use std::env; +use std::fs; +use std::process::exit; +use std::thread; +use std::time::Duration; + +#[path = "rust-paths.rs"] +mod rust_paths; + +fn get_arg(name: &str) -> Option { + let args: Vec = env::args().collect(); + let flag = format!("--{}", name); + + if let Some(idx) = args.iter().position(|a| a == &flag) { + return args.get(idx + 1).cloned(); + } + + let env_name = name.to_uppercase().replace('-', "_"); + env::var(&env_name).ok().filter(|s| !s.is_empty()) +} + +fn set_output(key: &str, value: &str) { + if let Ok(output_file) = env::var("GITHUB_OUTPUT") { + if let Err(e) = fs::OpenOptions::new() + .create(true) + .append(true) + .open(&output_file) + .and_then(|mut f| { + use std::io::Write; + writeln!(f, "{}={}", key, value) + }) + { + eprintln!("Warning: Could not write to GITHUB_OUTPUT: {}", e); + } + } + println!("Output: {}={}", key, value); +} + +fn parse_count_arg(name: &str, default: u64) -> u64 { + get_arg(name) + .and_then(|value| { + value.parse::().map_or_else( + |_| { + eprintln!( + "Warning: Invalid {} value '{}'; using default {}", + name, value, default + ); + None + }, + Some, + ) + }) + .unwrap_or(default) +} + +fn crate_version_exists(crate_name: &str, version: &str) -> bool { + let url = format!("https://crates.io/api/v1/crates/{}/{}", crate_name, version); + + match ureq::get(&url) + .set("User-Agent", "rust-script-wait-for-crate") + .call() + { + Ok(response) => response.status() == 200, + Err(ureq::Error::Status(404, _)) => false, + Err(e) => { + eprintln!("Warning: Could not check crates.io: {}", e); + false + } + } +} + +fn should_skip_crate_wait(crate_name: &str) -> bool { + crate_name == "example-sum-package-name" +} + +fn main() { + let rust_root = match rust_paths::get_rust_root(None, true) { + Ok(root) => root, + Err(e) => { + eprintln!("Error: {}", e); + exit(1); + } + }; + let cargo_toml = rust_paths::get_cargo_toml_path(&rust_root); + let package_manifest = match rust_paths::get_package_manifest_path(&cargo_toml) { + Ok(path) => path, + Err(e) => { + eprintln!("Error: {}", e); + exit(1); + } + }; + let package_info = match rust_paths::read_package_info(&package_manifest) { + Ok(info) => info, + Err(e) => { + eprintln!("Error: {}", e); + exit(1); + } + }; + + let crate_name = get_arg("crate-name").unwrap_or(package_info.name); + let version = get_arg("release-version").unwrap_or(package_info.version); + let max_attempts = parse_count_arg("max-attempts", 30); + let sleep_seconds = parse_count_arg("sleep-seconds", 10); + + if should_skip_crate_wait(&crate_name) { + println!( + "Skipping crates.io availability wait: package name is the template default '{}'", + crate_name + ); + set_output("crate_available", "skipped"); + return; + } + + for attempt in 1..=max_attempts { + if crate_version_exists(&crate_name, &version) { + println!( + "{}@{} is visible on crates.io after attempt {}", + crate_name, version, attempt + ); + set_output("crate_available", "true"); + return; + } + + if attempt < max_attempts { + println!( + "{}@{} is not visible on crates.io yet (attempt {}/{}); waiting {}s", + crate_name, version, attempt, max_attempts, sleep_seconds + ); + thread::sleep(Duration::from_secs(sleep_seconds)); + } + } + + eprintln!( + "Error: {}@{} was not visible on crates.io after {} attempts", + crate_name, version, max_attempts + ); + exit(1); +} + +#[cfg(test)] +mod tests { + use super::should_skip_crate_wait; + + #[test] + fn skips_template_default_package_name() { + assert!(should_skip_crate_wait("example-sum-package-name")); + } + + #[test] + fn waits_for_real_package_names() { + assert!(!should_skip_crate_wait("real-package-name")); + } +} diff --git a/dev/log/issues/298/pulls/299/templates/rust-workflows.txt b/dev/log/issues/298/pulls/299/templates/rust-workflows.txt new file mode 100644 index 00000000..fcca446f --- /dev/null +++ b/dev/log/issues/298/pulls/299/templates/rust-workflows.txt @@ -0,0 +1,5 @@ +desktop-release.yml +links.yml +release.yml +security.yml +workflows.yml diff --git a/dev/log/issues/298/pulls/299/templates/rust/desktop-release.yml b/dev/log/issues/298/pulls/299/templates/rust/desktop-release.yml new file mode 100644 index 00000000..f888ceb4 --- /dev/null +++ b/dev/log/issues/298/pulls/299/templates/rust/desktop-release.yml @@ -0,0 +1,171 @@ +name: Desktop Release + +on: + pull_request: + types: [opened, synchronize, reopened] + workflow_run: + workflows: [CI/CD Pipeline] + types: [completed] + workflow_dispatch: + inputs: + tag: + description: Release tag (defaults to the latest published release) + required: false + type: string + +permissions: + contents: read + +concurrency: + group: desktop-release-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + resolve: + name: Resolve release + if: ${{ vars.DESKTOP_RELEASE_ENABLED == 'true' && (github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success') }} + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + tag: ${{ steps.release.outputs.tag }} + should_build: ${{ steps.release.outputs.should_build }} + steps: + - uses: actions/checkout@v6 + - name: Resolve release tag + id: release + env: + GH_TOKEN: ${{ github.token }} + EVENT: ${{ github.event_name }} + INPUT_TAG: ${{ inputs.tag }} + REPO: ${{ github.repository }} + WORKFLOW_RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + run: bash scripts/desktop-release-resolve.sh + + build: + name: Build ${{ matrix.label }} + needs: resolve + if: ${{ !cancelled() && (github.event_name == 'pull_request' || needs.resolve.outputs.should_build == 'true') }} + strategy: + fail-fast: false + matrix: + include: + - { label: linux-x64, runner: ubuntu-latest } + - { label: linux-arm64, runner: ubuntu-24.04-arm } + - { label: macos-x64, runner: macos-15-intel } + - { label: macos-arm64, runner: macos-14 } + - { label: windows-x64, runner: windows-latest } + - { label: windows-arm64, runner: windows-11-arm } + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + permissions: + contents: read + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event_name != 'pull_request' && needs.resolve.outputs.tag || '' }} + - uses: dtolnay/rust-toolchain@stable + - name: Package desktop application + env: + DESKTOP_RELEASE_TAG: ${{ github.event_name == 'pull_request' && 'dry-run' || needs.resolve.outputs.tag }} + # electron-builder 26 requires both values for certificate-free PR builds: + CSC_FOR_PULL_REQUEST: 'true' + CSC_IDENTITY_AUTO_DISCOVERY: 'false' + run: bash scripts/package-desktop.sh "${{ matrix.label }}" desktop-dist + - name: Smoke test release assets + env: + OUTPUT_DIR: desktop-dist + run: | + set -euo pipefail + first_asset="$(find "$OUTPUT_DIR" -type f -size +0c -print -quit)" + [ -n "$first_asset" ] || { echo "No non-empty desktop assets were produced" >&2; exit 1; } + find "$OUTPUT_DIR" -type f -size +0c -print | sed 's/^/Verified /' + # Electron apps must retain a signature envelope. Use + # electron-builder --mac ... -c.mac.identity=- and set + # CSC_FOR_PULL_REQUEST=true on the ad-hoc path. + while IFS= read -r app; do + [ -f "$app/Contents/_CodeSignature/CodeResources" ] || { + echo "Missing macOS CodeResources signature envelope: $app" >&2; exit 1; + } + done < <(find "$OUTPUT_DIR" -type d -name '*.app' -print) + - name: Create checksum fragment + run: | + set -euo pipefail + cd desktop-dist + if command -v sha256sum >/dev/null; then + find . -type f -print0 | sort -z | xargs -0 sha256sum + else + find . -type f -exec shasum -a 256 {} + + fi | sed 's# \./# #' > "SHA256SUMS-${{ matrix.label }}.partial" + - name: Upload packaged assets + uses: actions/upload-artifact@v7 + with: + name: desktop-assets-${{ matrix.label }} + path: desktop-dist + retention-days: 7 + - name: Upload checksum fragment + uses: actions/upload-artifact@v7 + with: + name: desktop-checksums-${{ matrix.label }} + path: desktop-dist/SHA256SUMS-${{ matrix.label }}.partial + retention-days: 7 + + finalize: + name: Publish checksums and provenance + needs: [resolve, build] + if: ${{ !cancelled() && github.event_name != 'pull_request' && needs.build.result == 'success' && needs.resolve.outputs.should_build == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + actions: read + contents: write + id-token: write + attestations: write + steps: + - uses: actions/download-artifact@v8 + env: + NODE_OPTIONS: --disable-warning=DEP0005 + with: + pattern: desktop-assets-* + path: release-assets + - uses: actions/download-artifact@v8 + env: + NODE_OPTIONS: --disable-warning=DEP0005 + with: + pattern: desktop-checksums-* + path: checksums + merge-multiple: true + - name: Consolidate release metadata + env: + TAG: ${{ needs.resolve.outputs.tag }} + run: | + set -euo pipefail + cat checksums/*.partial | sort -k2 > SHA256SUMS.txt + { + echo 'Desktop build provenance' + echo '========================' + echo 'Status : complete' + echo 'Repository : ${{ github.repository }}' + echo "Release tag: $TAG" + echo 'Targets : linux-x64 linux-arm64 macos-x64 macos-arm64 windows-x64 windows-arm64' + echo 'Attestation: verify each asset with gh attestation verify --repo ${{ github.repository }}' + echo 'Run : ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' + } > BUILD-PROVENANCE.txt + - name: Attest build provenance + uses: actions/attest@v4 + with: + subject-path: | + release-assets/**/* + SHA256SUMS.txt + - name: Upload release metadata + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.resolve.outputs.tag }} + run: | + set -euo pipefail + mapfile -d '' assets < <(find release-assets -type f ! -name '*.partial' -print0) + [ ${#assets[@]} -gt 0 ] || { echo 'No release assets were downloaded' >&2; exit 1; } + gh release upload "$TAG" "${assets[@]}" SHA256SUMS.txt BUILD-PROVENANCE.txt \ + --repo "${{ github.repository }}" --clobber diff --git a/dev/log/issues/298/pulls/299/templates/rust/links.yml b/dev/log/issues/298/pulls/299/templates/rust/links.yml new file mode 100644 index 00000000..ae7003ad --- /dev/null +++ b/dev/log/issues/298/pulls/299/templates/rust/links.yml @@ -0,0 +1,111 @@ +name: Broken Link Checker + +on: + push: + branches: + - main + paths: + - '**.md' + - '**.html' + - '.github/workflows/links.yml' + - '.lycheeignore' + - 'scripts/check-web-archive.mjs' + - 'scripts/check-web-archive.test.mjs' + - 'scripts/fixtures/lychee-report.md' + pull_request: + types: [opened, synchronize, reopened] + paths: + - '**.md' + - '**.html' + - '.github/workflows/links.yml' + - '.lycheeignore' + - 'scripts/check-web-archive.mjs' + - 'scripts/check-web-archive.test.mjs' + - 'scripts/fixtures/lychee-report.md' + workflow_dispatch: + +# Least-privilege default; jobs escalate individually when needed. +permissions: + contents: read + +# Provide Git config to actions/checkout itself; checkout runs git init before +# any workflow step can configure Git. +env: + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: main + +jobs: + link-checker: + name: Check Links + runs-on: ubuntu-latest + # Typical run: <1min with lychee cache. 10min prevents slow + # external hosts or Wayback Machine probes from hanging the workflow. + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-link-checker + cancel-in-progress: true + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + + - name: Test Web Archive report parser + run: node --test scripts/check-web-archive.test.mjs + + - name: Check links with lychee + id: lychee + uses: lycheeverse/lychee-action@v2 + with: + # Check all Markdown and HTML files + # Exclude case-studies directory - these are research documents from + # external repos with references to files and issues that don't exist + # in this repository (similar exclusion pattern as eslint.config.js) + args: >- + --verbose + --no-progress + --cache + --max-cache-age 1d + --max-retries 3 + --timeout 30 + --exclude-path docs/case-studies + --exclude-path scripts/fixtures + './**/*.md' + './**/*.html' + # Don't fail the workflow immediately - we want to check web archive first + fail: false + # Output file for broken links report (used by check-web-archive.mjs) + output: lychee/out.md + # Write a job summary + jobSummary: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Check broken links against Web Archive + if: steps.lychee.outputs.exit_code != 0 + id: webarchive + run: node scripts/check-web-archive.mjs + env: + LYCHEE_OUTPUT: lychee/out.md + + - name: Fail if broken links were found + if: always() && steps.lychee.outputs.exit_code != 0 + run: | + echo "::error::Broken live links were detected." + echo "" + echo "What happened:" + echo " lychee found one or more broken links in the *.md and *.html files of this repository." + echo " A Web Archive snapshot is a suggested replacement; it does not fix the broken source link." + echo "" + echo "How to fix:" + echo " 1. Review the 'Check links with lychee' step above for a full list of broken links." + echo " 2. Review the Web Archive step when it ran. For links marked with a '::notice::' annotation," + echo " a Web Archive version exists." + echo " Replace those broken links with the suggested archive.org URL." + echo " 3. For links with no archive version, either:" + echo " a. Find an updated URL that points to the same or equivalent content." + echo " b. Remove the link if the content is no longer relevant." + echo " c. Add the URL to .lycheeignore if it is a known false positive." + echo "" + echo "Report location: lychee/out.md (available as a workflow artifact if configured)." + exit 1 diff --git a/dev/log/issues/298/pulls/299/templates/rust/release.yml b/dev/log/issues/298/pulls/299/templates/rust/release.yml new file mode 100644 index 00000000..4bf700f1 --- /dev/null +++ b/dev/log/issues/298/pulls/299/templates/rust/release.yml @@ -0,0 +1,1095 @@ +name: CI/CD Pipeline + +on: + push: + branches: + - main + pull_request: + types: [opened, synchronize, reopened] + workflow_dispatch: + inputs: + release_mode: + description: 'Manual release mode' + required: true + type: choice + default: 'instant' + options: + - instant + - changelog-pr + bump_type: + description: 'Version bump type' + required: true + type: choice + options: + - patch + - minor + - major + description: + description: 'Release description (optional)' + required: false + type: string + +# Least-privilege default for every job; jobs that need more escalate explicitly. +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: -Dwarnings + # Provide Git's initial branch before actions/checkout runs git init so + # Git 2.54+ does not emit the upcoming Git 3.0 default-branch hint. + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: main + # Harden Cargo registry index and crate downloads against transient network + # flakes on GitHub-hosted runners, including curl HTTP/2 framing failures. + CARGO_NET_RETRY: '10' + CARGO_HTTP_MULTIPLEXING: 'false' + # Support both CARGO_REGISTRY_TOKEN (cargo's native env var) and CARGO_TOKEN (for backwards compatibility) + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN || secrets.CARGO_TOKEN }} + CARGO_TOKEN: ${{ secrets.CARGO_TOKEN }} + # Optional: set repository variable DOCKERHUB_IMAGE to namespace/image to publish Docker Hub releases. + DOCKERHUB_IMAGE: ${{ vars.DOCKERHUB_IMAGE }} + +# Concurrency is intentionally job-scoped. Each read-only job cancels only its +# superseded counterpart for the same non-main ref. Main checks are not +# cancelled, so a cancelled main job is always exceptional and the terminal +# status gate can safely report it as a failure. Jobs that can +# mutate repositories, registries, releases, or Pages all use one shared +# concurrency group instead. A write that has started is never cancelled, and +# GitHub retains at most one pending run for the group. +jobs: + # === DETECT CHANGES - determines which jobs should run === + detect-changes: + name: Detect Changes + runs-on: ubuntu-latest + timeout-minutes: 5 + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-detect-changes + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + if: github.event_name != 'workflow_dispatch' + outputs: + rs-changed: ${{ steps.changes.outputs.rs-changed }} + toml-changed: ${{ steps.changes.outputs.toml-changed }} + workflow-changed: ${{ steps.changes.outputs.workflow-changed }} + any-code-changed: ${{ steps.changes.outputs.any-code-changed }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install rust-script + run: ./scripts/install-rust-script.sh + + - name: Detect changes + id: changes + env: + GITHUB_EVENT_NAME: ${{ github.event_name }} + run: rust-script scripts/detect-code-changes.rs + + # === CHANGELOG CHECK - only runs on PRs with code changes === + # Docs-only PRs (./docs folder, markdown files) don't require changelog fragments + changelog: + name: Changelog Fragment Check + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-changelog + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: [detect-changes] + if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any-code-changed == 'true' + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install rust-script + run: ./scripts/install-rust-script.sh + + - name: Check for changelog fragments + env: + GITHUB_BASE_REF: ${{ github.base_ref }} + run: rust-script scripts/check-changelog-fragment.rs + + # === VERSION CHECK - prevents manual version modification in PRs === + # This ensures versions are only modified by the automated release pipeline + version-check: + name: Version Modification Check + runs-on: ubuntu-latest + timeout-minutes: 5 + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-version-check + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install rust-script + run: ./scripts/install-rust-script.sh + + - name: Check for manual version changes + env: + GITHUB_EVENT_NAME: ${{ github.event_name }} + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_BASE_REF: ${{ github.base_ref }} + run: rust-script scripts/check-version-modification.rs + + # === SECRETS SCAN === + # Language-agnostic credential scan; runs via npx so no Node dependency is committed. + secrets-scan: + name: Secrets Scan + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-secrets-scan + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + steps: + - uses: actions/checkout@v6 + + - name: Run secretlint + run: npx --yes -p secretlint -p @secretlint/secretlint-rule-preset-recommend secretlint --secretlintignore .gitignore "**/*" + + # === FRESH MERGE SIMULATION === + # Catches semantic merge conflicts: a pull request that is green in isolation but + # breaks the base branch once merged into its current tip. + fresh-merge: + name: Fresh Merge Simulation + runs-on: ubuntu-latest + timeout-minutes: 30 + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-fresh-merge + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: [detect-changes] + if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any-code-changed == 'true' + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Cache cargo registry + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-fresh-merge-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-fresh-merge- + + - name: Simulate fresh merge with base branch + env: + GITHUB_BASE_REF: ${{ github.base_ref }} + FRESH_MERGE_BUDGET_SECONDS: 1200 + run: >- + bash scripts/run-with-budget-warning.sh "$FRESH_MERGE_BUDGET_SECONDS" "Fresh merge simulation" + bash scripts/simulate-fresh-merge.sh + + # === DOCKER IMAGE BUILD CHECK === + # Builds the image at pull-request stage without pushing, so a Dockerfile + # regression fails the pull request instead of producing a half-finished release + # (crate published, image missing). push: false also works for fork pull requests + # that have no registry credentials. + docker-build: + name: Docker Image Build Check + runs-on: ubuntu-latest + # The image compiles the crate from scratch; 30 minutes is not enough cold. + # The build step is a `uses:` action, so it cannot be wrapped in + # scripts/run-with-budget-warning.sh; docker/build-push-action owns its own + # process and there is no shell command to budget. This job is therefore an + # accepted exemption from the budget invariant (see tests/unit/ci-cd/issue_135.rs). + timeout-minutes: 60 + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-docker-build + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: [detect-changes] + if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any-code-changed == 'true' + steps: + - uses: actions/checkout@v6 + + - name: Check for Dockerfile + id: dockerfile + run: | + if [ -f Dockerfile ]; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + echo "No Dockerfile at repository root; skipping image build check." + fi + + - name: Set up Docker Buildx + if: steps.dockerfile.outputs.exists == 'true' + uses: docker/setup-buildx-action@v4 + + - name: Build image (no push) + if: steps.dockerfile.outputs.exists == 'true' + uses: docker/build-push-action@v7 + with: + context: . + push: false + load: true + tags: app:pr-check + cache-from: type=gha + cache-to: type=gha,mode=max + + # === CARGO.LOCK GUARD === + # Binary crates must commit Cargo.lock so CI and release jobs resolve the same + # dependency graph every run. This also prevents cache keys based on + # hashFiles('**/Cargo.lock') from silently degrading to the empty hash. + cargo-lock: + name: Cargo.lock Guard + runs-on: ubuntu-latest + timeout-minutes: 5 + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-cargo-lock + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: [detect-changes] + if: | + !cancelled() && ( + github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.rs-changed == 'true' || + needs.detect-changes.outputs.toml-changed == 'true' || + needs.detect-changes.outputs.workflow-changed == 'true' || + needs.detect-changes.outputs.any-code-changed == 'true' + ) + steps: + - uses: actions/checkout@v6 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install rust-script + run: ./scripts/install-rust-script.sh + + - name: Check committed Cargo.lock for binary crates + run: rust-script scripts/check-cargo-lock.rs + + # === LINT AND FORMAT CHECK === + # Lint runs independently of changelog check - it's a fast check that should always run + # See: https://github.com/link-assistant/hive-mind/pull/1024 for why this dependency was removed + lint: + name: Lint and Format Check + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-lint + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: [detect-changes, cargo-lock] + # Note: !cancelled() is required because detect-changes is skipped on workflow_dispatch, + # and without it, this job would also be skipped even though its condition includes workflow_dispatch. + # See: https://github.com/actions/runner/issues/491 + if: | + !cancelled() && ( + needs.cargo-lock.result == 'success' && ( + github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.rs-changed == 'true' || + needs.detect-changes.outputs.toml-changed == 'true' || + needs.detect-changes.outputs.workflow-changed == 'true' || + needs.detect-changes.outputs.any-code-changed == 'true' + ) + ) + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Install rust-script + run: ./scripts/install-rust-script.sh + + - name: Cache cargo registry + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-lint-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-lint- + + - name: Check formatting + run: cargo fmt --all -- --check + + - name: Run Clippy + run: cargo clippy --all-targets --all-features + + # Warning-band annotations are limited to files this pull request changed, + # so unchanged files stop repeating the same warning on every run. + # The 1000-line hard limit still applies to the whole repository. + - name: Collect changed files + id: changed-files + env: + BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} + run: | + { + echo 'files</dev/null; then + git diff --name-only "$BASE_SHA" HEAD + else + # No usable base (e.g. workflow_dispatch): fall back to annotating every warning. + git ls-files + fi + echo 'CHANGED_FILES_EOF' + } >> "$GITHUB_OUTPUT" + + - name: Build documentation + # Fail-closed rustdoc gate: rustdoc-only lints (for example + # rustdoc::private_intra_doc_links) are not reported by clippy or + # `cargo test --doc`, so they must be caught here, before release, + # instead of in the post-release deploy-docs job. + env: + RUSTDOCFLAGS: -D warnings + run: cargo doc --no-deps --all-features + + - name: Check file size limit + env: + CHANGED_FILES: ${{ steps.changed-files.outputs.files }} + run: rust-script scripts/check-file-size.rs + + # === TEST === + # Test runs independently of changelog check and only for code-affecting changes + test: + name: Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + # Backstop only. The budgeted steps below own the real deadline; see + # scripts/run-with-budget-warning.sh for why that distinction matters. + timeout-minutes: 30 + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-test-${{ matrix.os }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: [detect-changes, cargo-lock] + # Note: !cancelled() is required because detect-changes is skipped on workflow_dispatch, + # and without it, this job would also be skipped even though its condition includes workflow_dispatch. + # See: https://github.com/actions/runner/issues/491 + if: | + !cancelled() && ( + needs.cargo-lock.result == 'success' && ( + github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.any-code-changed == 'true' || + needs.detect-changes.outputs.rs-changed == 'true' || + needs.detect-changes.outputs.toml-changed == 'true' || + needs.detect-changes.outputs.workflow-changed == 'true' + ) + ) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v6 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry on Windows + if: runner.os == 'Windows' + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + key: ${{ runner.os }}-cargo-test-registry-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-test-registry- + + - name: Cache cargo registry and target on Unix + if: runner.os != 'Windows' + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-test-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-test- + + - name: Run tests + shell: bash + env: + TEST_BUDGET_SECONDS: 900 + run: >- + bash scripts/run-with-budget-warning.sh "$TEST_BUDGET_SECONDS" "Test suite" + cargo test --all-features --verbose + + - name: Run doc tests + shell: bash + env: + DOC_TEST_BUDGET_SECONDS: 300 + run: >- + bash scripts/run-with-budget-warning.sh "$DOC_TEST_BUDGET_SECONDS" "Doc tests" + cargo test --doc --verbose + + # === CODE COVERAGE === + # Generate and upload code coverage using cargo-llvm-cov + coverage: + name: Code Coverage + runs-on: ubuntu-latest + timeout-minutes: 15 + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-coverage + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: [detect-changes, cargo-lock] + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + if: | + !cancelled() && ( + needs.cargo-lock.result == 'success' && ( + github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.rs-changed == 'true' || + needs.detect-changes.outputs.toml-changed == 'true' + ) + ) + steps: + - uses: actions/checkout@v6 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + components: llvm-tools-preview + + - name: Cache cargo registry + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-coverage-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-coverage- + + - name: Install cargo-llvm-cov + uses: taiki-e/install-action@cargo-llvm-cov + + - name: Generate code coverage + env: + COVERAGE_BUDGET_SECONDS: 600 + run: >- + bash scripts/run-with-budget-warning.sh "$COVERAGE_BUDGET_SECONDS" "Code coverage" + cargo llvm-cov --all-features --lcov --output-path lcov.info + + - name: Upload coverage to Codecov + if: env.CODECOV_TOKEN != '' + uses: codecov/codecov-action@v7 + with: + token: ${{ env.CODECOV_TOKEN }} + files: lcov.info + disable_search: true + fail_ci_if_error: true + + - name: Report skipped Codecov upload + if: env.CODECOV_TOKEN == '' + run: echo "::notice::Skipping Codecov upload because CODECOV_TOKEN is not configured" + + # === BUILD === + # Build package - only runs if lint and test pass + build: + name: Build Package + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-build + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: [lint, test] + if: ${{ !cancelled() && needs.lint.result == 'success' && needs.test.result == 'success' }} + steps: + - uses: actions/checkout@v6 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install rust-script + run: ./scripts/install-rust-script.sh + + - name: Cache cargo registry + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-build-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-build- + + - name: Build release + run: cargo build --release --verbose + + - name: Check package + run: cargo package --list --allow-dirty + + - name: Check crate package size + run: rust-script scripts/check-crate-size.rs + + # === AUTO RELEASE === + # Automatic release on push to main using changelog fragments + # This job automatically bumps version based on fragments in changelog.d/ + auto-release: + name: Auto Release + needs: [lint, test, build] + outputs: + docker_enabled: ${{ steps.dockerhub.outputs.enabled }} + release_version: ${{ steps.release-metadata.outputs.version }} + concurrency: + group: ${{ github.workflow }}-main-write + cancel-in-progress: false + # Note: !cancelled() ensures consistent behavior with other jobs that depend on skipped jobs. + if: | + !cancelled() && + github.event_name == 'push' && + github.ref == 'refs/heads/main' && + needs.build.result == 'success' + runs-on: ubuntu-latest + # Covers the crate build, publish, smoke test, and a cold Docker build. + timeout-minutes: 60 + env: + DOCKERHUB_USERNAME: ${{ vars.DOCKERHUB_USERNAME || secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install rust-script + run: ./scripts/install-rust-script.sh + + - name: Configure git + run: rust-script scripts/git-config.rs + + - name: Determine bump type from changelog fragments + id: bump_type + run: rust-script scripts/get-bump-type.rs + + - name: Check if version already released or no fragments + id: check + env: + HAS_FRAGMENTS: ${{ steps.bump_type.outputs.has_fragments }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: rust-script scripts/check-release-needed.rs + + - name: Collect changelog and bump version + id: version + if: steps.check.outputs.should_release == 'true' && steps.check.outputs.skip_bump != 'true' + run: | + rust-script scripts/version-and-commit.rs \ + --bump-type "${{ steps.bump_type.outputs.bump_type }}" + + - name: Get current version + id: current_version + if: steps.check.outputs.should_release == 'true' + run: rust-script scripts/get-version.rs + + - name: Build release + if: steps.check.outputs.should_release == 'true' + run: cargo build --release + + - name: Check crate package size + if: steps.check.outputs.should_release == 'true' && steps.check.outputs.crate_published != 'true' + run: rust-script scripts/check-crate-size.rs + + - name: Publish to Crates.io + if: steps.check.outputs.should_release == 'true' && steps.check.outputs.crate_published != 'true' + id: publish-crate + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN || secrets.CARGO_TOKEN }} + CARGO_TOKEN: ${{ secrets.CARGO_TOKEN }} + run: rust-script scripts/publish-crate.rs + + - name: Wait for Crate availability on Crates.io + if: | + steps.check.outputs.should_release == 'true' && ( + steps.check.outputs.crate_published == 'true' || + steps.publish-crate.outputs.publish_result == 'success' + ) + run: rust-script scripts/wait-for-crate.rs --release-version "${{ steps.current_version.outputs.version }}" + + - name: Smoke-test published crate + if: | + steps.check.outputs.should_release == 'true' && ( + steps.check.outputs.crate_published == 'true' || + steps.publish-crate.outputs.publish_result == 'success' + ) + run: rust-script scripts/smoke-test-published-crate.rs --release-version "${{ steps.current_version.outputs.version }}" + + - name: Configure Docker Hub publishing + if: | + steps.check.outputs.should_release == 'true' && ( + steps.check.outputs.crate_published == 'true' || + steps.publish-crate.outputs.publish_result == 'success' + ) + id: dockerhub + run: | + disable_dockerhub() { + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "$1" + } + + if [ -z "$DOCKERHUB_IMAGE" ]; then + disable_dockerhub "Docker Hub publishing disabled: DOCKERHUB_IMAGE repository variable is not set" + exit 0 + fi + + if [ ! -f Dockerfile ]; then + disable_dockerhub "Docker Hub publishing disabled: Dockerfile was not found at repository root" + exit 0 + fi + + echo "enabled=true" >> "$GITHUB_OUTPUT" + echo "docker_hub_url=https://hub.docker.com/r/${DOCKERHUB_IMAGE}" >> "$GITHUB_OUTPUT" + + - name: Resolve release metadata + id: release-metadata + if: | + steps.check.outputs.should_release == 'true' && ( + steps.check.outputs.crate_published == 'true' || + steps.publish-crate.outputs.publish_result == 'success' + ) + run: | + release_version="${{ steps.version.outputs.new_version }}" + if [ -z "$release_version" ]; then + release_version="${{ steps.current_version.outputs.version }}" + fi + echo "version=$release_version" >> "$GITHUB_OUTPUT" + + - name: Create GitHub Release + if: | + steps.check.outputs.should_release == 'true' && ( + steps.check.outputs.crate_published == 'true' || + steps.publish-crate.outputs.publish_result == 'success' + ) + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DOCKER_HUB_URL: ${{ steps.dockerhub.outputs.docker_hub_url }} + run: | + release_args=( + --release-version "${{ steps.release-metadata.outputs.version }}" + --repository "${{ github.repository }}" + ) + if [ -n "$DOCKER_HUB_URL" ]; then + release_args+=(--docker-hub-url "$DOCKER_HUB_URL") + fi + rust-script scripts/create-github-release.rs "${release_args[@]}" + + # === MANUAL INSTANT RELEASE === + # Manual release via workflow_dispatch - only after CI passes + manual-release: + name: Instant Release + needs: [lint, test, build] + outputs: + docker_enabled: ${{ steps.dockerhub.outputs.enabled }} + release_version: ${{ steps.version.outputs.new_version }} + concurrency: + group: ${{ github.workflow }}-main-write + cancel-in-progress: false + # Note: !cancelled() is required to evaluate the condition when dependencies may be skipped. + # The build job ensures lint and test passed before this job runs. + if: | + !cancelled() && + github.event_name == 'workflow_dispatch' && + github.event.inputs.release_mode == 'instant' && + needs.build.result == 'success' + runs-on: ubuntu-latest + # Covers the crate build, publish, smoke test, and a cold Docker build. + timeout-minutes: 60 + env: + DOCKERHUB_USERNAME: ${{ vars.DOCKERHUB_USERNAME || secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install rust-script + run: ./scripts/install-rust-script.sh + + - name: Configure git + run: rust-script scripts/git-config.rs + + - name: Collect changelog fragments + run: rust-script scripts/collect-changelog.rs + + - name: Version and commit + id: version + env: + BUMP_TYPE: ${{ github.event.inputs.bump_type }} + DESCRIPTION: ${{ github.event.inputs.description }} + run: rust-script scripts/version-and-commit.rs --bump-type "$BUMP_TYPE" --description "$DESCRIPTION" + + - name: Build release + if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' + run: cargo build --release + + - name: Check crate package size + if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' + run: rust-script scripts/check-crate-size.rs + + - name: Publish to Crates.io + if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' + id: publish-crate + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN || secrets.CARGO_TOKEN }} + CARGO_TOKEN: ${{ secrets.CARGO_TOKEN }} + run: rust-script scripts/publish-crate.rs + + - name: Wait for Crate availability on Crates.io + if: | + (steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true') && + steps.publish-crate.outputs.publish_result == 'success' + run: rust-script scripts/wait-for-crate.rs --release-version "${{ steps.version.outputs.new_version }}" + + - name: Smoke-test published crate + if: | + (steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true') && + steps.publish-crate.outputs.publish_result == 'success' + run: rust-script scripts/smoke-test-published-crate.rs --release-version "${{ steps.version.outputs.new_version }}" + + - name: Configure Docker Hub publishing + if: | + (steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true') && + steps.publish-crate.outputs.publish_result == 'success' + id: dockerhub + run: | + disable_dockerhub() { + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "$1" + } + + if [ -z "$DOCKERHUB_IMAGE" ]; then + disable_dockerhub "Docker Hub publishing disabled: DOCKERHUB_IMAGE repository variable is not set" + exit 0 + fi + + if [ ! -f Dockerfile ]; then + disable_dockerhub "Docker Hub publishing disabled: Dockerfile was not found at repository root" + exit 0 + fi + + echo "enabled=true" >> "$GITHUB_OUTPUT" + echo "docker_hub_url=https://hub.docker.com/r/${DOCKERHUB_IMAGE}" >> "$GITHUB_OUTPUT" + + - name: Create GitHub Release + if: | + (steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true') && + steps.publish-crate.outputs.publish_result == 'success' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DOCKER_HUB_URL: ${{ steps.dockerhub.outputs.docker_hub_url }} + run: | + release_args=( + --release-version "${{ steps.version.outputs.new_version }}" + --repository "${{ github.repository }}" + ) + if [ -n "$DOCKER_HUB_URL" ]; then + release_args+=(--docker-hub-url "$DOCKER_HUB_URL") + fi + rust-script scripts/create-github-release.rs "${release_args[@]}" + + # Build each architecture on a native runner. Publishing by digest lets both + # builds run in parallel without racing to update a shared image tag. + docker-publish: + name: Publish Docker / ${{ matrix.platform }} + needs: [auto-release, manual-release] + if: | + !cancelled() && + (needs.auto-release.result == 'success' || needs.manual-release.result == 'success') && + (needs.auto-release.outputs.docker_enabled == 'true' || needs.manual-release.outputs.docker_enabled == 'true') + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + env: + DOCKERHUB_USERNAME: ${{ vars.DOCKERHUB_USERNAME || secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + RELEASE_VERSION: ${{ needs.auto-release.outputs.release_version || needs.manual-release.outputs.release_version }} + steps: + - uses: actions/checkout@v6 + with: + ref: v${{ env.RELEASE_VERSION }} + + - name: Log in to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ env.DOCKERHUB_USERNAME }} + password: ${{ env.DOCKERHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: ./.github/actions/setup-buildx-resilient + + - name: Build and push architecture digest + id: build + uses: docker/build-push-action@v7 + with: + context: . + platforms: ${{ matrix.platform }} + labels: org.opencontainers.image.version=${{ env.RELEASE_VERSION }} + outputs: type=image,name=${{ env.DOCKERHUB_IMAGE }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Export digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v6 + with: + name: docker-digest-${{ strategy.job-index }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + docker-merge-manifest: + name: Publish Docker manifest + needs: [auto-release, manual-release, docker-publish] + if: | + !cancelled() && + needs.docker-publish.result == 'success' + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + DOCKERHUB_USERNAME: ${{ vars.DOCKERHUB_USERNAME || secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + RELEASE_VERSION: ${{ needs.auto-release.outputs.release_version || needs.manual-release.outputs.release_version }} + steps: + - name: Download digests + uses: actions/download-artifact@v7 + with: + path: /tmp/digests + pattern: docker-digest-* + merge-multiple: true + + - name: Log in to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ env.DOCKERHUB_USERNAME }} + password: ${{ env.DOCKERHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Create multi-architecture manifest + working-directory: /tmp/digests + run: | + mapfile -t digests < <(printf "${DOCKERHUB_IMAGE}@sha256:%s\n" *) + docker buildx imagetools create \ + --tag "${DOCKERHUB_IMAGE}:latest" \ + --tag "${DOCKERHUB_IMAGE}:${RELEASE_VERSION}" \ + "${digests[@]}" + + - name: Verify manifest platforms + run: | + manifest="$(docker buildx imagetools inspect "${DOCKERHUB_IMAGE}:${RELEASE_VERSION}")" + grep -F 'linux/amd64' <<< "$manifest" + grep -F 'linux/arm64' <<< "$manifest" + + # === MANUAL CHANGELOG PR === + changelog-pr: + name: Create Changelog PR + if: github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'changelog-pr' + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: ${{ github.workflow }}-main-write + cancel-in-progress: false + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install rust-script + run: ./scripts/install-rust-script.sh + + - name: Create changelog fragment + env: + BUMP_TYPE: ${{ github.event.inputs.bump_type }} + DESCRIPTION: ${{ github.event.inputs.description }} + run: rust-script scripts/create-changelog-fragment.rs --bump-type "$BUMP_TYPE" --description "$DESCRIPTION" + + - name: Create Pull Request + uses: peter-evans/create-pull-request@v8 + with: + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: 'chore: add changelog for manual ${{ github.event.inputs.bump_type }} release' + branch: changelog-manual-release-${{ github.run_id }} + delete-branch: true + title: 'chore: manual ${{ github.event.inputs.bump_type }} release' + body: | + ## Manual Release Request + + This PR was created by a manual workflow trigger to prepare a **${{ github.event.inputs.bump_type }}** release. + + ### Release Details + - **Type:** ${{ github.event.inputs.bump_type }} + - **Description:** ${{ github.event.inputs.description || 'Manual release' }} + - **Triggered by:** @${{ github.actor }} + + ### Next Steps + 1. Review the changelog fragment in this PR + 2. Merge this PR to main + 3. The automated release workflow will publish to crates.io and create a GitHub release + + # === DEPLOY DOCUMENTATION === + # Deploy Rust API documentation to GitHub Pages after a successful package build. + # Keep this independent from package/GitHub release publication so the website + # still updates when the release path fails. Use the official Pages artifact + # deployment path so repositories configured with "GitHub Actions" as their + # Pages source fail this job if Pages cannot deploy. + # + # One-time setup: in the repository's Settings -> Pages, set Source to + # "GitHub Actions". Without this, the first run fails on actions/deploy-pages + # with "Get Pages site failed" / "Failed to create deployment". This cannot be + # configured from a workflow. See README.md "Deploying API documentation". + deploy-docs: + name: Deploy Rust Documentation + needs: [build] + concurrency: + group: ${{ github.workflow }}-main-write + cancel-in-progress: false + if: | + !cancelled() && + needs.build.result == 'success' && ( + (github.event_name == 'push' && github.ref == 'refs/heads/main') || + (github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'instant') + ) + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v6 + with: + ref: main + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Build documentation + # Defense in depth: same fail-closed flags as the lint job's gate. + env: + RUSTDOCFLAGS: -D warnings + run: cargo doc --no-deps --all-features + + - name: Generate Pages root index + run: | + # rustdoc emits target/doc//index.html but no root index.html, + # which makes the GitHub Pages root URL return 404. Redirect the root + # to the crate docs directory that rustdoc generated. + crate=$(cargo metadata --no-deps --format-version 1 \ + | python3 -c 'import json,sys; print(json.load(sys.stdin)["packages"][0]["name"].replace("-","_"))') + printf '\n' "$crate" \ + > target/doc/index.html + touch target/doc/.nojekyll + + - name: Add desktop download page + run: | + mkdir -p target/doc/download + sed 's#__REPOSITORY__#${{ github.repository }}#g' \ + docs/download/index.html > target/doc/download/index.html + + - name: Configure GitHub Pages + uses: actions/configure-pages@v6 + + - name: Verify site tree + run: find target/doc -maxdepth 2 -print + + - name: Upload GitHub Pages artifact + uses: actions/upload-pages-artifact@v5 + with: + path: target/doc + include-hidden-files: true + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 + + # === PIPELINE STATUS === + # GitHub reports jobs killed by timeout-minutes as cancelled. Observe every + # job so a timeout on main becomes a visible failure instead of a grey run. + pipeline-status: + name: Pipeline Status + needs: + - detect-changes + - changelog + - version-check + - secrets-scan + - fresh-merge + - docker-build + - cargo-lock + - lint + - test + - coverage + - build + - auto-release + - manual-release + - docker-publish + - docker-merge-manifest + - changelog-pr + - deploy-docs + if: always() + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v6 + + - name: Check aggregate pipeline status + env: + NEEDS_JSON: ${{ toJSON(needs) }} + IS_MAIN: ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' }} + run: bash scripts/check-pipeline-status.sh diff --git a/dev/log/issues/298/pulls/299/templates/rust/security.yml b/dev/log/issues/298/pulls/299/templates/rust/security.yml new file mode 100644 index 00000000..ab7cd08d --- /dev/null +++ b/dev/log/issues/298/pulls/299/templates/rust/security.yml @@ -0,0 +1,74 @@ +name: Security + +on: + push: + branches: [main] + pull_request: + schedule: + - cron: '0 6 * * 1' + +# Keep the workflow read-only unless a job explicitly needs an additional scope. +permissions: + contents: read + +jobs: + cargo-audit: + name: Cargo audit + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-cargo-audit + cancel-in-progress: true + steps: + - uses: actions/checkout@v6 + + - uses: taiki-e/install-action@v2 + with: + tool: cargo-audit@0.22.2 + + - name: Audit committed Cargo.lock + run: cargo audit --file Cargo.lock + + codeql: + name: CodeQL (${{ matrix.language }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-codeql-${{ matrix.language }} + cancel-in-progress: true + permissions: + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + language: [rust, actions] + steps: + - uses: actions/checkout@v6 + + - uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + + - uses: github/codeql-action/autobuild@v4 + + - uses: github/codeql-action/analyze@v4 + + dependency-review: + name: Dependency Review + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-dependency-review + cancel-in-progress: true + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v6 + + - uses: actions/dependency-review-action@v5 + with: + fail-on-severity: high + comment-summary-in-pr: on-failure diff --git a/dev/log/issues/298/pulls/299/templates/rust/workflows.yml b/dev/log/issues/298/pulls/299/templates/rust/workflows.yml new file mode 100644 index 00000000..e900e57c --- /dev/null +++ b/dev/log/issues/298/pulls/299/templates/rust/workflows.yml @@ -0,0 +1,33 @@ +name: Workflows + +on: + push: + branches: [main] + paths: ['.github/**'] + pull_request: + paths: ['.github/**'] + +# Keep the workflow read-only unless a job explicitly needs an additional scope. +permissions: + contents: read + +jobs: + actionlint: + name: Actionlint + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-actionlint + cancel-in-progress: true + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + # The Docker image bundles shellcheck and pyflakes, so this also lints + # every `run:` block. A *native* actionlint binary without shellcheck on + # PATH silently skips the shell checks and exits 0, which is how the + # SC2016 defect in release.yml (issue #141) went unnoticed. + - uses: docker://rhysd/actionlint:1.7.7 + with: + args: -color diff --git a/experiments/issue-298/registry-user-agent-probe.sh b/experiments/issue-298/registry-user-agent-probe.sh new file mode 100755 index 00000000..f191b513 --- /dev/null +++ b/experiments/issue-298/registry-user-agent-probe.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Reproduces the root cause of https://github.com/link-foundation/links-notation/issues/298 +# +# crates.io rejects requests that carry curl's default `User-Agent: curl/8.x` +# with HTTP 403. Every other registry used by this repository answers 200. +# +# `rust.yml` verified a freshly published crate with a bare `curl -fsS ...`, +# so the verification could never succeed: the crate was published, the poll +# 403'd twenty times in a row, and the `rust` workflow reported a failure for a +# release that had actually gone out. A false negative. +# +# Usage: experiments/issue-298/registry-user-agent-probe.sh +set -uo pipefail + +UA='links-notation-ci (https://github.com/link-foundation/links-notation)' + +probe() { + local label="$1" url="$2" + local bare with + bare=$(curl -sS -o /dev/null -w '%{http_code}' "$url" 2>/dev/null || echo 'ERR') + with=$(curl -sS -o /dev/null -w '%{http_code}' -H "User-Agent: ${UA}" "$url" 2>/dev/null || echo 'ERR') + printf '%-12s default-UA=%-4s explicit-UA=%-4s %s\n' "$label" "$bare" "$with" \ + "$([ "$bare" = "$with" ] && echo 'same' || echo '<-- DIFFERS')" +} + +echo 'Registry reachability with curl'"'"'s default User-Agent vs an explicit one:' +echo +probe crates.io "https://crates.io/api/v1/crates/links-notation/0.16.0" +probe npm "https://registry.npmjs.org/links-notation" +probe pypi "https://pypi.org/pypi/links-notation/json" +probe nuget "https://api.nuget.org/v3-flatcontainer/link.foundation.links.notation/index.json" +probe goproxy "https://proxy.golang.org/github.com/link-foundation/links-notation/go/@v/list" +probe packagist "https://repo.packagist.org/p2/link-foundation/links-notation.json" +echo +echo 'Expected: only crates.io differs (403 without a User-Agent, 200 with one).' diff --git a/go/VERSION b/go/VERSION index 04a373ef..2a0970ca 100644 --- a/go/VERSION +++ b/go/VERSION @@ -1 +1 @@ -0.16.0 +0.16.1 diff --git a/java/pom.xml b/java/pom.xml index 8624edab..6a798a84 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -6,7 +6,7 @@ io.github.link-foundation links-notation - 0.16.0 + 0.16.1 jar Links Notation diff --git a/js/package-lock.json b/js/package-lock.json index 320ee7db..3b187355 100644 --- a/js/package-lock.json +++ b/js/package-lock.json @@ -1,12 +1,12 @@ { "name": "links-notation", - "version": "0.16.0", + "version": "0.16.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "links-notation", - "version": "0.16.0", + "version": "0.16.1", "license": "Unlicense", "devDependencies": { "bun-types": "^1.4.0", diff --git a/js/package.json b/js/package.json index a2924eee..79758943 100644 --- a/js/package.json +++ b/js/package.json @@ -1,6 +1,6 @@ { "name": "links-notation", - "version": "0.16.0", + "version": "0.16.1", "description": "Links Notation parser for JavaScript", "main": "dist/index.js", "types": "index.d.ts", diff --git a/php/composer.json b/php/composer.json index ce0162b1..dd6073e6 100644 --- a/php/composer.json +++ b/php/composer.json @@ -5,7 +5,7 @@ "keywords": ["links", "notation", "lino", "parser", "formatter", "links-platform"], "homepage": "https://github.com/link-foundation/links-notation", "license": "Unlicense", - "version": "0.16.0", + "version": "0.16.1", "authors": [ { "name": "Link Foundation", diff --git a/python/pyproject.toml b/python/pyproject.toml index ebb44fcf..d948c164 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "links-notation" -version = "0.16.0" +version = "0.16.1" description = "Python implementation of the Links Notation parser" readme = "README.md" license = "Unlicense" diff --git a/rust/links-notation/Cargo.toml b/rust/links-notation/Cargo.toml index 4d533053..4e8e4cd8 100644 --- a/rust/links-notation/Cargo.toml +++ b/rust/links-notation/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "links-notation" -version = "0.16.0" +version = "0.16.1" edition = "2021" description = "Rust implementation of the Links Notation parser" license = "Unlicense" diff --git a/scripts/ci/registry-probe.sh b/scripts/ci/registry-probe.sh new file mode 100755 index 00000000..9940b5b0 --- /dev/null +++ b/scripts/ci/registry-probe.sh @@ -0,0 +1,175 @@ +#!/usr/bin/env bash +# Shared registry HTTP probe for the publish workflows. +# +# Every language workflow polls its package registry to confirm that a release +# really landed. Those polls used to be bare `curl -fsS "$URL" >/dev/null 2>&1` +# calls, which had two defects: +# +# 1. crates.io answers 403 to curl's default `User-Agent: curl/8.x`, so the +# rust workflow could never verify a crate it had just published +# successfully. See https://github.com/link-foundation/links-notation/issues/298 +# and https://github.com/rust-lang/crates.io/issues/13482. +# 2. `>/dev/null 2>&1` discarded the status code, so twenty identical +# "Not visible yet" lines were the only evidence in the log and the real +# cause (403, not 404) was invisible. +# +# Both are fixed here, once, so the individual workflows cannot drift apart +# again. +# +# probe_registry -> 0 when the URL answers 2xx +# wait_for_registry