From 3c3e4f758c1cf74217331a01d250e39e94017259 Mon Sep 17 00:00:00 2001 From: Hyeonseo Kim Date: Tue, 3 Nov 2026 16:56:01 +0900 Subject: [PATCH 1/8] Split main workflow into reusable workflows Separate CI, release, publishing, and documentation jobs by responsibility while keeping main.yaml as the release caller for npm trusted publishing. AI provenance: The contributor asked Codex to plan and implement issue #170 while preserving OIDC permissions, artifact reuse, release ordering, concurrency, rerun safety, and documentation preview security. Codex inspected the issue and connected code, proposed the reusable-workflow structure, generated the workflow and supporting script and documentation changes, and helped distinguish the release caller from the image-attestation signer. The contributor decided to keep main.yaml as the npm trusted-publisher caller, omit a changelog entry because this is an internal chore, and retain the existing publication and documentation gate ordering. The contributor reviewed the result and verified it with the pull request's check, test, docs, and four image-pr jobs, all of which passed. Assisted-by: Codex:gpt-5.6-sol --- .github/workflows/artifacts.yaml | 210 ++++++++ .github/workflows/check.yaml | 38 ++ .github/workflows/docs-canary.yaml | 88 ++++ .github/workflows/docs-preview.yaml | 4 +- .github/workflows/docs-release.yaml | 85 ++++ .github/workflows/docs.yaml | 87 ++++ .github/workflows/github-release.yaml | 44 ++ .github/workflows/main.yaml | 701 ++------------------------ .github/workflows/publish.yaml | 109 ++++ .github/workflows/test.yaml | 113 +++++ .github/workflows/version.yaml | 40 ++ DEPLOY.md | 17 +- mise.toml | 2 +- scripts/docs-preview.mts | 2 +- scripts/release.mts | 11 +- scripts/test-docs-preview.mts | 2 +- scripts/test-release-image.mts | 23 +- 17 files changed, 896 insertions(+), 680 deletions(-) create mode 100644 .github/workflows/artifacts.yaml create mode 100644 .github/workflows/check.yaml create mode 100644 .github/workflows/docs-canary.yaml create mode 100644 .github/workflows/docs-release.yaml create mode 100644 .github/workflows/docs.yaml create mode 100644 .github/workflows/github-release.yaml create mode 100644 .github/workflows/publish.yaml create mode 100644 .github/workflows/test.yaml create mode 100644 .github/workflows/version.yaml diff --git a/.github/workflows/artifacts.yaml b/.github/workflows/artifacts.yaml new file mode 100644 index 0000000..bdca47c --- /dev/null +++ b/.github/workflows/artifacts.yaml @@ -0,0 +1,210 @@ +name: artifacts + +on: + workflow_call: + inputs: + build: + description: The calculated release build identity + required: true + type: string + +permissions: + contents: read + +jobs: + artifacts: + runs-on: ubuntu-latest + timeout-minutes: 45 + permissions: + contents: read + actions: read + concurrency: + group: artifacts-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + queue: max + env: + DRFED_RELEASE_BUILD: ${{ inputs.build }} + ARTIFACT_NAME: drfed-${{ github.run_id }} + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + - uses: jdx/mise-action@v5.2.6 + with: + cache: false + experimental: true + - name: Find the original verified artifacts from this run + id: original + env: + GH_TOKEN: ${{ github.token }} + run: | + artifact_id=$(gh api --paginate "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/artifacts" --jq '.artifacts[] | select(.name == env.ARTIFACT_NAME and .expired == false) | .id') + if [[ -n "$artifact_id" ]]; then + echo 'reuse=true' >> "$GITHUB_OUTPUT" + else + mise run release:ensure-unpublished + echo 'reuse=false' >> "$GITHUB_OUTPUT" + fi + - uses: actions/download-artifact@v8.0.2 + if: steps.original.outputs.reuse == 'true' + with: + name: ${{ env.ARTIFACT_NAME }} + path: release-artifacts + - name: Apply the single calculated registry version before building + run: mise run release:apply + - name: Build the versioned packages + run: mise run build + - name: Pack the complete public package set + if: steps.original.outputs.reuse != 'true' + run: mise run release:pack + - name: Verify artifact identity and installed behavior + run: mise run release:verify && mise run --skip-deps test:packaging --artifacts release-artifacts + - uses: actions/upload-artifact@v7.0.2 + if: steps.original.outputs.reuse != 'true' + with: + name: ${{ env.ARTIFACT_NAME }} + path: release-artifacts/ + if-no-files-found: error + retention-days: 90 + overwrite: false + + image: + needs: [artifacts] + timeout-minutes: 45 + permissions: + contents: read + actions: read + concurrency: + group: image-${{ github.workflow }}-${{ github.ref }}-${{ matrix.arch }} + cancel-in-progress: false + queue: max + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + runner: ubuntu-latest + - arch: arm64 + runner: ubuntu-26.04-arm + runs-on: ${{ matrix.runner }} + env: + DRFED_RELEASE_BUILD: ${{ inputs.build }} + ARTIFACT_NAME: drfed-image-${{ github.run_id }}-${{ matrix.arch }} + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + - uses: jdx/mise-action@v5.2.6 + with: + cache: false + experimental: true + # A rerun keeps the layout that was tested first; a rebuild would not + # reproduce its digest. Without it, a version already on npm must not get + # new image bytes. + - name: Find the original image layout from this run + id: original + env: + GH_TOKEN: ${{ github.token }} + run: | + artifact_id=$(gh api --paginate "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/artifacts" --jq '.artifacts[] | select(.name == env.ARTIFACT_NAME and .expired == false) | .id') + if [[ -n "$artifact_id" ]]; then + echo 'reuse=true' >> "$GITHUB_OUTPUT" + else + mise run release:ensure-unpublished + echo 'reuse=false' >> "$GITHUB_OUTPUT" + fi + - uses: actions/download-artifact@v8.0.2 + if: steps.original.outputs.reuse != 'true' + with: + name: drfed-${{ github.run_id }} + path: release-artifacts + - name: Verify the package tarballs the image is built from + if: steps.original.outputs.reuse != 'true' + run: mise run release:verify + - uses: ./.github/actions/containerd-image-store + if: steps.original.outputs.reuse != 'true' + - name: Build the image from the verified tarballs + if: steps.original.outputs.reuse != 'true' + run: >- + mise run --skip-deps image:build --engine docker + --artifacts release-artifacts --platform "linux/${{ matrix.arch }}" + # The bytes to be published must also run in Podman. + - uses: ./.github/actions/pasta-apparmor + if: steps.original.outputs.reuse != 'true' + - name: Start the Podman API socket + if: steps.original.outputs.reuse != 'true' + run: systemctl --user start podman.socket + - name: Test the exact image layout with Docker, then Podman + if: steps.original.outputs.reuse != 'true' + run: mise run test:image --require + - uses: actions/upload-artifact@v7.0.2 + if: steps.original.outputs.reuse != 'true' + with: + name: ${{ env.ARTIFACT_NAME }} + path: image-artifacts/linux-${{ matrix.arch }}/ + if-no-files-found: error + compression-level: 0 + retention-days: 90 + overwrite: false + + image-index: + needs: [artifacts, image] + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + actions: read + concurrency: + group: image-index-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + queue: max + env: + DRFED_RELEASE_BUILD: ${{ inputs.build }} + ARTIFACT_NAME: drfed-image-index-${{ github.run_id }} + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + - uses: jdx/mise-action@v5.2.6 + with: + cache: false + experimental: true + - name: Find the original image index from this run + id: original + env: + GH_TOKEN: ${{ github.token }} + run: | + artifact_id=$(gh api --paginate "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/artifacts" --jq '.artifacts[] | select(.name == env.ARTIFACT_NAME and .expired == false) | .id') + if [[ -n "$artifact_id" ]]; then + echo 'reuse=true' >> "$GITHUB_OUTPUT" + else + echo 'reuse=false' >> "$GITHUB_OUTPUT" + fi + - uses: actions/download-artifact@v8.0.2 + with: + name: drfed-${{ github.run_id }} + path: release-artifacts + - uses: actions/download-artifact@v8.0.2 + with: + name: drfed-image-${{ github.run_id }}-amd64 + path: image-artifacts/linux-amd64 + - uses: actions/download-artifact@v8.0.2 + with: + name: drfed-image-${{ github.run_id }}-arm64 + path: image-artifacts/linux-arm64 + - uses: actions/download-artifact@v8.0.2 + if: steps.original.outputs.reuse == 'true' + with: + name: ${{ env.ARTIFACT_NAME }} + path: image-artifacts/index + - name: Merge the platform images into one OCI image index + run: mise run release:image-index + - uses: actions/upload-artifact@v7.0.2 + if: steps.original.outputs.reuse != 'true' + with: + name: ${{ env.ARTIFACT_NAME }} + path: image-artifacts/index/ + if-no-files-found: error + compression-level: 0 + retention-days: 90 + overwrite: false diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml new file mode 100644 index 0000000..56ee139 --- /dev/null +++ b/.github/workflows/check.yaml @@ -0,0 +1,38 @@ +name: check + +on: + pull_request: + # Validate branch pushes even before a PR exists; PRs also cover forks. + push: # jactionlint ignore=duplicate-triggers + branches: ["**", "!main"] + workflow_call: + +permissions: + contents: read + +jobs: + check: + timeout-minutes: 30 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + queue: max + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: jdx/mise-action@v5.2.6 + with: + cache: true + experimental: true + log_level: ${{ runner.debug == '1' && 'trace' || 'info' }} + - name: Verify migration history against the PR base + if: github.event_name == 'pull_request' + run: mise run manifest:migrations --base "$MIGRATION_BASE" + env: + MIGRATION_BASE: ${{ github.event.pull_request.base.sha }} + - run: mise build && mise check + env: + MISE_LOG_LEVEL: ${{ runner.debug == '1' && 'trace' || 'info' }} diff --git a/.github/workflows/docs-canary.yaml b/.github/workflows/docs-canary.yaml new file mode 100644 index 0000000..7c41760 --- /dev/null +++ b/.github/workflows/docs-canary.yaml @@ -0,0 +1,88 @@ +name: docs-canary + +on: + workflow_call: + inputs: + build: + description: The calculated release build identity + required: true + type: string + secrets: + CLOUDFLARE_API_TOKEN: + required: true + CLOUDFLARE_ACCOUNT_ID: + required: true + +permissions: + contents: read + +jobs: + docs-canary: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + deployments: write + concurrency: + group: docs-canary + cancel-in-progress: false + queue: max + environment: + name: canary-docs + url: ${{ vars.CANARY_DOCS_URL }} + env: + DRFED_RELEASE_BUILD: ${{ inputs.build }} + # Nothing but Node.js and Wrangler is installed, and no step may run + # repository hooks or install anything else: every task runs with + # --no-deps and --skip-tools, so no project dependencies are installed + # either, and the deployment step finds Wrangler already installed. + MISE_NO_HOOKS: "1" + MISE_AUTO_INSTALL: "false" + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + # Keep the Wrangler version in sync with the docs:deploy task in + # mise.toml. If they differ, the deployment fails without installing. + - uses: jdx/mise-action@v5.2.6 + with: + cache: false + experimental: true + install_args: node npm:wrangler@4.149.0 + - name: Stop unless this build is newer than the deployed docs + id: current + run: mise run --no-deps --skip-tools docs:verify-current + env: + GH_TOKEN: ${{ github.token }} + DRFED_DOCS_URL: ${{ vars.CANARY_DOCS_URL }} + # Outside the checkout: wrangler warns about, and records, a dirty tree. + - uses: actions/download-artifact@v8.0.2 + if: steps.current.outputs.current == 'true' + with: + name: docs-${{ github.run_id }} + path: ${{ runner.temp }}/docs-dist + - name: Record the intent to deploy this build + id: intent + if: steps.current.outputs.current == 'true' + run: mise run --no-deps --skip-tools docs:record-intent "$RUNNER_TEMP/docs-dist" + env: + GH_TOKEN: ${{ github.token }} + # The task refuses anything that Cloudflare would run as code before it + # runs wrangler. Only this step receives the Cloudflare secrets, and it + # never receives a GitHub token. + - name: Deploy the documentation site to Cloudflare Pages + if: steps.current.outputs.current == 'true' + run: mise run --no-deps --skip-tools docs:deploy "$RUNNER_TEMP/docs-dist" + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_PROJECT_NAME: ${{ vars.CLOUDFLARE_PROJECT_NAME }} + DOCS_BRANCH: main + DOCS_COMMIT: ${{ github.sha }} + - name: Record the successful docs deployment + if: steps.current.outputs.current == 'true' + run: mise run --no-deps --skip-tools docs:record-deployment + env: + GH_TOKEN: ${{ github.token }} + DOCS_DEPLOYMENT_ID: ${{ steps.intent.outputs.deployment }} + DRFED_DOCS_URL: ${{ vars.CANARY_DOCS_URL }} diff --git a/.github/workflows/docs-preview.yaml b/.github/workflows/docs-preview.yaml index d48d557..3911fdc 100644 --- a/.github/workflows/docs-preview.yaml +++ b/.github/workflows/docs-preview.yaml @@ -3,7 +3,7 @@ name: docs-preview # Read "Pull request previews" in DEPLOY.md before changing this workflow. on: workflow_run: # jactionlint ignore=dangerous-triggers - workflows: [main] + workflows: [docs] types: [completed] permissions: @@ -14,7 +14,7 @@ jobs: if: >- github.repository == 'fedify-dev/drfed' && github.event.workflow_run.event == 'pull_request' && - github.event.workflow_run.path == '.github/workflows/main.yaml' && + github.event.workflow_run.path == '.github/workflows/docs.yaml' && github.event.workflow_run.conclusion != 'cancelled' runs-on: ubuntu-latest timeout-minutes: 15 diff --git a/.github/workflows/docs-release.yaml b/.github/workflows/docs-release.yaml new file mode 100644 index 0000000..e686ed7 --- /dev/null +++ b/.github/workflows/docs-release.yaml @@ -0,0 +1,85 @@ +name: docs-release + +on: + workflow_call: + inputs: + build: + description: The calculated release build identity + required: true + type: string + +permissions: + contents: read + +jobs: + docs-release: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + pages: write + id-token: write + deployments: write + concurrency: + group: docs-release + cancel-in-progress: false + queue: max + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + env: + DRFED_RELEASE_BUILD: ${{ inputs.build }} + PAGES_ARTIFACT: github-pages-${{ github.run_id }}-${{ github.run_attempt }} + # Nothing but Node.js is installed, and no step may run repository hooks + # or install anything else: every task runs with --no-deps and + # --skip-tools, so no project dependencies are installed either. + MISE_NO_HOOKS: "1" + MISE_AUTO_INSTALL: "false" + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + - uses: jdx/mise-action@v5.2.6 + with: + cache: false + experimental: true + install_args: node + - name: Stop unless this release is newer than the deployed docs + id: current + run: mise run --no-deps --skip-tools docs:verify-current + env: + GH_TOKEN: ${{ github.token }} + DRFED_DOCS_URL: ${{ vars.RELEASE_DOCS_URL }} + # Outside the checkout, so that nothing downloaded can shadow its files. + - uses: actions/download-artifact@v8.0.2 + if: steps.current.outputs.current == 'true' + with: + name: docs-${{ github.run_id }} + path: ${{ runner.temp }}/docs-dist + - name: Refuse a documentation build that is not plain static files + if: steps.current.outputs.current == 'true' + run: mise run --no-deps --skip-tools docs:verify-dist "$RUNNER_TEMP/docs-dist" + - name: Record the intent to deploy this release + id: intent + if: steps.current.outputs.current == 'true' + run: mise run --no-deps --skip-tools docs:record-intent "$RUNNER_TEMP/docs-dist" + env: + GH_TOKEN: ${{ github.token }} + - uses: actions/upload-pages-artifact@v5.0.0 + if: steps.current.outputs.current == 'true' + with: + name: ${{ env.PAGES_ARTIFACT }} + path: ${{ runner.temp }}/docs-dist + retention-days: 1 + - id: deployment + if: steps.current.outputs.current == 'true' + uses: actions/deploy-pages@v5.0.1 + with: + artifact_name: ${{ env.PAGES_ARTIFACT }} + - name: Record the successful docs deployment + if: steps.current.outputs.current == 'true' + run: mise run --no-deps --skip-tools docs:record-deployment + env: + GH_TOKEN: ${{ github.token }} + DOCS_DEPLOYMENT_ID: ${{ steps.intent.outputs.deployment }} + DRFED_DOCS_URL: ${{ steps.deployment.outputs.page_url }} diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml new file mode 100644 index 0000000..5e20f8d --- /dev/null +++ b/.github/workflows/docs.yaml @@ -0,0 +1,87 @@ +name: docs + +on: + pull_request: + paths: + - .github/workflows/docs.yaml + - CHANGES.md + - docker/** + - docs/** + - mise.toml + - package.json + - pnpm-lock.yaml + - pnpm-workspace.yaml + - scripts/images/** + # Validate branch pushes even before a PR exists; PRs also cover forks. + push: # jactionlint ignore=duplicate-triggers + branches: ["**", "!main"] + paths: + - .github/workflows/docs.yaml + - CHANGES.md + - docker/** + - docs/** + - mise.toml + - package.json + - pnpm-lock.yaml + - pnpm-workspace.yaml + - scripts/images/** + workflow_call: + inputs: + build: + description: The calculated release build identity, if this run deploys + required: false + type: string + default: "" + +permissions: + contents: read + +jobs: + docs: + timeout-minutes: 15 + concurrency: + group: docs-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + queue: max + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + - uses: jdx/mise-action@v5.2.6 + with: + cache: true + experimental: true + # Only the builds that the docs-release and docs-canary jobs deploy link + # to the other site, and they fail fast without its URL. Pull requests, + # other branches, and pushes to forks or other repositories build without + # the link, whatever variables they define. + - name: Build the documentation site for its deployment target + run: | + if [[ "$DEPLOYS" == true && "$GITHUB_REF_TYPE" == tag ]]; then + : "${CANARY_DOCS_URL:?The CANARY_DOCS_URL variable is required}" + export EXTRA_NAV_TEXT=Canary EXTRA_NAV_LINK="$CANARY_DOCS_URL" + DRFED_IMAGE_VERSION=$(jq --raw-output .version <<<"$DRFED_RELEASE_BUILD") + export DRFED_IMAGE_VERSION + elif [[ "$DEPLOYS" == true && "$GITHUB_REF" == refs/heads/main ]]; then + : "${RELEASE_DOCS_URL:?The RELEASE_DOCS_URL variable is required}" + export EXTRA_NAV_TEXT=Release EXTRA_NAV_LINK="$RELEASE_DOCS_URL" + DRFED_IMAGE_VERSION=$(jq --raw-output .version <<<"$DRFED_RELEASE_BUILD") + export DRFED_IMAGE_VERSION + fi + mise run build:docs + env: + DEPLOYS: >- + ${{ github.event_name == 'push' && + github.repository == 'fedify-dev/drfed' && !github.event.deleted }} + RELEASE_DOCS_URL: ${{ vars.RELEASE_DOCS_URL }} + CANARY_DOCS_URL: ${{ vars.CANARY_DOCS_URL }} + DRFED_RELEASE_BUILD: ${{ inputs.build }} + # The docs-preview workflow deploys this artifact for pull requests. + - uses: actions/upload-artifact@v7.0.2 + with: + name: docs-${{ github.run_id }} + path: docs/.vitepress/dist + if-no-files-found: error + retention-days: 14 + overwrite: true diff --git a/.github/workflows/github-release.yaml b/.github/workflows/github-release.yaml new file mode 100644 index 0000000..643e707 --- /dev/null +++ b/.github/workflows/github-release.yaml @@ -0,0 +1,44 @@ +name: github-release + +on: + workflow_call: + inputs: + build: + description: The calculated release build identity + required: true + type: string + +permissions: + contents: read + +jobs: + github-release: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + concurrency: + group: npm-publish + cancel-in-progress: false + queue: max + env: + DRFED_RELEASE_BUILD: ${{ inputs.build }} + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + - uses: jdx/mise-action@v5.2.6 + with: + cache: false + experimental: true + - uses: actions/download-artifact@v8.0.2 + with: + name: drfed-${{ github.run_id }} + path: release-artifacts + - uses: actions/download-artifact@v8.0.2 + with: + name: drfed-image-index-${{ github.run_id }} + path: image-artifacts/index + - name: Create or verify the frozen release and its exact assets + run: mise run release:github diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index d62a116..c06e768 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -1,709 +1,100 @@ name: main on: - pull_request: - # Validate branch pushes even before a PR exists; PRs also cover forks. - push: # jactionlint ignore=duplicate-triggers - branches: ["**"] + push: + branches: [main] tags: ["*.*.*"] permissions: contents: read +concurrency: + group: ${{ github.workflow }}-caller-${{ github.ref }} + cancel-in-progress: false + queue: max + jobs: check: - timeout-minutes: 30 - concurrency: - group: check-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: false - queue: max - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7.0.1 - with: - fetch-depth: 0 - persist-credentials: false - - uses: jdx/mise-action@v5.2.6 - with: - cache: true - experimental: true - log_level: ${{ runner.debug == '1' && 'trace' || 'info' }} - - name: Verify migration history against the PR base - if: github.event_name == 'pull_request' - run: mise run manifest:migrations --base "$MIGRATION_BASE" - env: - MIGRATION_BASE: ${{ github.event.pull_request.base.sha }} - - run: mise build && mise check - env: - MISE_LOG_LEVEL: ${{ runner.debug == '1' && 'trace' || 'info' }} - - # Script tests that do not depend on the OS run once, outside the test matrix. - test-scripts: - name: test (scripts) - timeout-minutes: 30 - concurrency: - group: test-scripts-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: false - queue: max - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7.0.1 - with: - persist-credentials: false - - uses: jdx/mise-action@v5.2.6 - with: - cache: true - experimental: true - log_level: ${{ runner.debug == '1' && 'trace' || 'info' }} - - run: mise run test:scripts - env: - MISE_LOG_LEVEL: ${{ runner.debug == '1' && 'trace' || 'info' }} + uses: ./.github/workflows/check.yaml test: - name: test (${{ matrix.os }}, shard ${{ matrix.shard }}) - timeout-minutes: 30 - concurrency: - group: >- - test-${{ github.workflow }}-${{ github.ref }}-${{ matrix.os }}-${{ matrix.shard }} - cancel-in-progress: false - queue: max - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest-m, macos-latest, windows-latest-l] - shard: ["1/3", "2/3", "3/3"] - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v7.0.1 - with: - persist-credentials: false - - uses: jdx/mise-action@v5.2.6 - with: - cache: true - experimental: true - log_level: ${{ runner.debug == '1' && 'trace' || 'info' }} - - run: mise test --shard ${{ matrix.shard }} - env: - MISE_LOG_LEVEL: ${{ runner.debug == '1' && 'trace' || 'info' }} - LOGTAPE_TEST_MODE: ${{ runner.debug == '1' && 'always' || 'on-failure' }} - LOGTAPE_TEST_LOWEST_LEVEL: ${{ runner.debug == '1' && 'trace' || 'debug' }} - - # Pull requests and other branches build the image from the workspace with - # each container engine and test it with that engine on both architectures; - # nothing is uploaded or published. - image-pr: - needs: [version] - # The version job is skipped outside main and tags, but a failed one means - # the ref is broken and no image needs testing. - if: >- - ${{ !cancelled() && needs.version.result != 'failure' && - needs.version.outputs.enabled != 'true' }} - timeout-minutes: 45 - concurrency: - group: image-pr-${{ github.workflow }}-${{ github.ref }}-${{ matrix.engine }}-${{ matrix.arch }} - cancel-in-progress: false - queue: max - strategy: - fail-fast: false - matrix: - engine: [docker, podman] - arch: [amd64, arm64] - include: - - arch: amd64 - runner: ubuntu-latest - - arch: arm64 - runner: ubuntu-26.04-arm - runs-on: ${{ matrix.runner }} - steps: - - uses: actions/checkout@v7.0.1 - with: - persist-credentials: false - - uses: jdx/mise-action@v5.2.6 - with: - cache: true - experimental: true - log_level: ${{ runner.debug == '1' && 'trace' || 'info' }} - - uses: ./.github/actions/containerd-image-store - if: matrix.engine == 'docker' - - uses: ./.github/actions/pasta-apparmor - if: matrix.engine == 'podman' - # podman compose hands the default API address to its provider. - - name: Start the Podman API socket - if: matrix.engine == 'podman' - run: systemctl --user start podman.socket - - name: Show the Podman toolchain - if: matrix.engine == 'podman' - run: podman version && podman compose version - - name: Build the image from the packed workspace - run: mise run image:build --engine "${{ matrix.engine }}" - - name: Test the exact image layout - run: mise run "test:image:${{ matrix.engine }}" --require + uses: ./.github/workflows/test.yaml + with: + release: >- + ${{ github.repository == 'fedify-dev/drfed' && !github.event.deleted }} version: - if: >- - github.event_name == 'push' && github.repository == 'fedify-dev/drfed' && - !github.event.deleted && - (github.ref == 'refs/heads/main' || github.ref_type == 'tag') - runs-on: ubuntu-latest - timeout-minutes: 10 - concurrency: - group: version-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: false - queue: max - outputs: - enabled: ${{ steps.version.outputs.enabled }} - build: ${{ steps.version.outputs.build }} - steps: - - uses: actions/checkout@v7.0.1 - with: - persist-credentials: false - - uses: jdx/mise-action@v5.2.6 - with: - cache: false - experimental: true - - id: version - run: mise run release:version + uses: ./.github/workflows/version.yaml artifacts: - needs: [version, check, test, test-scripts] - if: needs.version.outputs.enabled == 'true' - runs-on: ubuntu-latest - timeout-minutes: 45 - permissions: - contents: read - actions: read - concurrency: - group: artifacts-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: false - queue: max - env: - DRFED_RELEASE_BUILD: ${{ needs.version.outputs.build }} - ARTIFACT_NAME: drfed-${{ github.run_id }} - steps: - - uses: actions/checkout@v7.0.1 - with: - persist-credentials: false - - uses: jdx/mise-action@v5.2.6 - with: - cache: false - experimental: true - - name: Find the original verified artifacts from this run - id: original - env: - GH_TOKEN: ${{ github.token }} - run: | - artifact_id=$(gh api --paginate "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/artifacts" --jq '.artifacts[] | select(.name == env.ARTIFACT_NAME and .expired == false) | .id') - if [[ -n "$artifact_id" ]]; then - echo 'reuse=true' >> "$GITHUB_OUTPUT" - else - mise run release:ensure-unpublished - echo 'reuse=false' >> "$GITHUB_OUTPUT" - fi - - uses: actions/download-artifact@v8.0.2 - if: steps.original.outputs.reuse == 'true' - with: - name: ${{ env.ARTIFACT_NAME }} - path: release-artifacts - - name: Apply the single calculated registry version before building - run: mise run release:apply - - name: Build the versioned packages - run: mise run build - - name: Pack the complete public package set - if: steps.original.outputs.reuse != 'true' - run: mise run release:pack - - name: Verify artifact identity and installed behavior - run: mise run release:verify && mise run --skip-deps test:packaging --artifacts release-artifacts - - uses: actions/upload-artifact@v7.0.2 - if: steps.original.outputs.reuse != 'true' - with: - name: ${{ env.ARTIFACT_NAME }} - path: release-artifacts/ - if-no-files-found: error - retention-days: 90 - overwrite: false - - image: - needs: [version, artifacts] - if: needs.version.outputs.enabled == 'true' - timeout-minutes: 45 - permissions: - contents: read - actions: read - concurrency: - group: image-${{ github.workflow }}-${{ github.ref }}-${{ matrix.arch }} - cancel-in-progress: false - queue: max - strategy: - fail-fast: false - matrix: - include: - - arch: amd64 - runner: ubuntu-latest - - arch: arm64 - runner: ubuntu-26.04-arm - runs-on: ${{ matrix.runner }} - env: - DRFED_RELEASE_BUILD: ${{ needs.version.outputs.build }} - ARTIFACT_NAME: drfed-image-${{ github.run_id }}-${{ matrix.arch }} - steps: - - uses: actions/checkout@v7.0.1 - with: - persist-credentials: false - - uses: jdx/mise-action@v5.2.6 - with: - cache: false - experimental: true - # A rerun keeps the layout that was tested first; a rebuild would not - # reproduce its digest. - # Without it, a version already on npm must not get new image bytes. - - name: Find the original image layout from this run - id: original - env: - GH_TOKEN: ${{ github.token }} - run: | - artifact_id=$(gh api --paginate "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/artifacts" --jq '.artifacts[] | select(.name == env.ARTIFACT_NAME and .expired == false) | .id') - if [[ -n "$artifact_id" ]]; then - echo 'reuse=true' >> "$GITHUB_OUTPUT" - else - mise run release:ensure-unpublished - echo 'reuse=false' >> "$GITHUB_OUTPUT" - fi - - uses: actions/download-artifact@v8.0.2 - if: steps.original.outputs.reuse != 'true' - with: - name: drfed-${{ github.run_id }} - path: release-artifacts - - name: Verify the package tarballs the image is built from - if: steps.original.outputs.reuse != 'true' - run: mise run release:verify - - uses: ./.github/actions/containerd-image-store - if: steps.original.outputs.reuse != 'true' - - name: Build the image from the verified tarballs - if: steps.original.outputs.reuse != 'true' - run: >- - mise run --skip-deps image:build --engine docker - --artifacts release-artifacts --platform "linux/${{ matrix.arch }}" - # The bytes to be published must also run in Podman. - - uses: ./.github/actions/pasta-apparmor - if: steps.original.outputs.reuse != 'true' - - name: Start the Podman API socket - if: steps.original.outputs.reuse != 'true' - run: systemctl --user start podman.socket - - name: Test the exact image layout with Docker, then Podman - if: steps.original.outputs.reuse != 'true' - run: mise run test:image --require - - uses: actions/upload-artifact@v7.0.2 - if: steps.original.outputs.reuse != 'true' - with: - name: ${{ env.ARTIFACT_NAME }} - path: image-artifacts/linux-${{ matrix.arch }}/ - if-no-files-found: error - compression-level: 0 - retention-days: 90 - overwrite: false - - image-index: - needs: [version, artifacts, image] + needs: [version, check, test] if: needs.version.outputs.enabled == 'true' - runs-on: ubuntu-latest - timeout-minutes: 15 permissions: contents: read actions: read - concurrency: - group: image-index-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: false - queue: max - env: - DRFED_RELEASE_BUILD: ${{ needs.version.outputs.build }} - ARTIFACT_NAME: drfed-image-index-${{ github.run_id }} - steps: - - uses: actions/checkout@v7.0.1 - with: - persist-credentials: false - - uses: jdx/mise-action@v5.2.6 - with: - cache: false - experimental: true - - name: Find the original image index from this run - id: original - env: - GH_TOKEN: ${{ github.token }} - run: | - artifact_id=$(gh api --paginate "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/artifacts" --jq '.artifacts[] | select(.name == env.ARTIFACT_NAME and .expired == false) | .id') - if [[ -n "$artifact_id" ]]; then - echo 'reuse=true' >> "$GITHUB_OUTPUT" - else - echo 'reuse=false' >> "$GITHUB_OUTPUT" - fi - - uses: actions/download-artifact@v8.0.2 - with: - name: drfed-${{ github.run_id }} - path: release-artifacts - - uses: actions/download-artifact@v8.0.2 - with: - name: drfed-image-${{ github.run_id }}-amd64 - path: image-artifacts/linux-amd64 - - uses: actions/download-artifact@v8.0.2 - with: - name: drfed-image-${{ github.run_id }}-arm64 - path: image-artifacts/linux-arm64 - - uses: actions/download-artifact@v8.0.2 - if: steps.original.outputs.reuse == 'true' - with: - name: ${{ env.ARTIFACT_NAME }} - path: image-artifacts/index - - name: Merge the platform images into one OCI image index - run: mise run release:image-index - - uses: actions/upload-artifact@v7.0.2 - if: steps.original.outputs.reuse != 'true' - with: - name: ${{ env.ARTIFACT_NAME }} - path: image-artifacts/index/ - if-no-files-found: error - compression-level: 0 - retention-days: 90 - overwrite: false + uses: ./.github/workflows/artifacts.yaml + with: + build: ${{ needs.version.outputs.build }} publish: - needs: [version, artifacts, image-index] + needs: [version, artifacts] if: >- - github.event_name == 'push' && github.repository == 'fedify-dev/drfed' && - !github.event.deleted && needs.version.outputs.enabled == 'true' - runs-on: ubuntu-latest - timeout-minutes: 20 + github.repository == 'fedify-dev/drfed' && !github.event.deleted && + needs.version.outputs.enabled == 'true' permissions: contents: read id-token: write - concurrency: - group: npm-publish - cancel-in-progress: false - queue: max - env: - DRFED_RELEASE_BUILD: ${{ needs.version.outputs.build }} - steps: - - uses: actions/checkout@v7.0.1 - with: - persist-credentials: false - - uses: jdx/mise-action@v5.2.6 - with: - cache: false - experimental: true - - uses: actions/download-artifact@v8.0.2 - with: - name: drfed-${{ github.run_id }} - path: release-artifacts - - name: Publish the verified tarballs with npm trusted publishing - env: - NPM_CONFIG_USERCONFIG: ${{ runner.temp }}/drfed-npmrc - run: mise run release:publish - - image-publish: - needs: [version, publish, image-index] - if: >- - github.event_name == 'push' && github.repository == 'fedify-dev/drfed' && - !github.event.deleted && needs.version.outputs.enabled == 'true' - runs-on: ubuntu-latest - timeout-minutes: 20 - permissions: - contents: read packages: write - id-token: write attestations: write artifact-metadata: write - concurrency: - group: npm-publish - cancel-in-progress: false - queue: max - env: - DRFED_RELEASE_BUILD: ${{ needs.version.outputs.build }} - GH_TOKEN: ${{ github.token }} - steps: - - uses: actions/checkout@v7.0.1 - with: - persist-credentials: false - - uses: jdx/mise-action@v5.2.6 - with: - cache: false - experimental: true - - uses: actions/download-artifact@v8.0.2 - with: - name: drfed-image-${{ github.run_id }}-amd64 - path: image-artifacts/linux-amd64 - - uses: actions/download-artifact@v8.0.2 - with: - name: drfed-image-${{ github.run_id }}-arm64 - path: image-artifacts/linux-arm64 - - uses: actions/download-artifact@v8.0.2 - with: - name: drfed-image-index-${{ github.run_id }} - path: image-artifacts/index - # crane, gh attestation, actions/attest and @sigstore/oci all read the - # credentials this step stores. - - name: Log in to the GitHub Container Registry - run: >- - printf '%s' "$GH_TOKEN" | - docker login ghcr.io --username "$GITHUB_ACTOR" --password-stdin - - name: Push the verified image index - id: push - run: mise run release:image-push - - name: Find which copies of the provenance attestation exist - id: attestation - run: mise run release:image-attestation status - # actions/attest writes to the registry; recheck the release tag first. - - name: Check that the release tag still names this commit - if: steps.attestation.outputs.state == 'none' - run: mise run release:tag-target - - uses: actions/attest@v4.2.2 - if: steps.attestation.outputs.state == 'none' - with: - subject-name: ${{ steps.push.outputs.subject }} - subject-digest: ${{ steps.push.outputs.digest }} - push-to-registry: true - - name: Attach the attestation stored on GitHub to the registry - if: steps.attestation.outputs.state == 'github-only' - run: mise run release:image-attestation attach - - name: Verify the attestation from GitHub and from the registry - run: mise run release:image-attestation verify - - name: Advance the channel tag and check anonymous pulls - run: mise run release:image-tags + uses: ./.github/workflows/publish.yaml + with: + build: ${{ needs.version.outputs.build }} github-release: - needs: [version, publish, image-publish] + needs: [version, publish] if: >- - github.event_name == 'push' && github.repository == 'fedify-dev/drfed' && - !github.event.deleted && github.ref_type == 'tag' && - needs.version.outputs.enabled == 'true' - runs-on: ubuntu-latest - timeout-minutes: 10 + github.repository == 'fedify-dev/drfed' && !github.event.deleted && + github.ref_type == 'tag' && needs.version.outputs.enabled == 'true' permissions: contents: write - concurrency: - group: npm-publish - cancel-in-progress: false - queue: max - env: - DRFED_RELEASE_BUILD: ${{ needs.version.outputs.build }} - GH_TOKEN: ${{ github.token }} - steps: - - uses: actions/checkout@v7.0.1 - with: - persist-credentials: false - - uses: jdx/mise-action@v5.2.6 - with: - cache: false - experimental: true - - uses: actions/download-artifact@v8.0.2 - with: - name: drfed-${{ github.run_id }} - path: release-artifacts - - uses: actions/download-artifact@v8.0.2 - with: - name: drfed-image-index-${{ github.run_id }} - path: image-artifacts/index - - name: Create or verify the frozen release and its exact assets - run: mise run release:github + uses: ./.github/workflows/github-release.yaml + with: + build: ${{ needs.version.outputs.build }} docs: - # Pull requests skip the version job and still build the docs. needs: [version] if: ${{ !cancelled() && needs.version.result != 'failure' }} - timeout-minutes: 15 - concurrency: - group: docs-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: false - queue: max - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7.0.1 - with: - persist-credentials: false - - uses: jdx/mise-action@v5.2.6 - with: - cache: true - experimental: true - # Only the builds that the docs-release and docs-canary jobs deploy link - # to the other site, and they fail fast without its URL. Pull requests, - # other branches, and pushes to forks or other repositories build - # without the link, whatever variables they define. - - name: Build the documentation site for its deployment target - run: | - if [[ "$DEPLOYS" == true && "$GITHUB_REF_TYPE" == tag ]]; then - : "${CANARY_DOCS_URL:?The CANARY_DOCS_URL variable is required}" - export EXTRA_NAV_TEXT=Canary EXTRA_NAV_LINK="$CANARY_DOCS_URL" - DRFED_IMAGE_VERSION=$(jq --raw-output .version <<<"$DRFED_RELEASE_BUILD") - export DRFED_IMAGE_VERSION - elif [[ "$DEPLOYS" == true && "$GITHUB_REF" == refs/heads/main ]]; then - : "${RELEASE_DOCS_URL:?The RELEASE_DOCS_URL variable is required}" - export EXTRA_NAV_TEXT=Release EXTRA_NAV_LINK="$RELEASE_DOCS_URL" - DRFED_IMAGE_VERSION=$(jq --raw-output .version <<<"$DRFED_RELEASE_BUILD") - export DRFED_IMAGE_VERSION - fi - mise run build:docs - env: - DEPLOYS: >- - ${{ github.event_name == 'push' && - github.repository == 'fedify-dev/drfed' && !github.event.deleted }} - RELEASE_DOCS_URL: ${{ vars.RELEASE_DOCS_URL }} - CANARY_DOCS_URL: ${{ vars.CANARY_DOCS_URL }} - DRFED_RELEASE_BUILD: ${{ needs.version.outputs.build }} - # The docs-preview workflow deploys this artifact for pull requests. - - uses: actions/upload-artifact@v7.0.2 - with: - name: docs-${{ github.run_id }} - path: docs/.vitepress/dist - if-no-files-found: error - retention-days: 14 - overwrite: true + uses: ./.github/workflows/docs.yaml + with: + build: ${{ needs.version.outputs.build }} docs-release: needs: [version, docs, github-release] if: >- - github.event_name == 'push' && github.repository == 'fedify-dev/drfed' && - !github.event.deleted && github.ref_type == 'tag' && - needs.version.outputs.enabled == 'true' - runs-on: ubuntu-latest - timeout-minutes: 15 + github.repository == 'fedify-dev/drfed' && !github.event.deleted && + github.ref_type == 'tag' && needs.version.outputs.enabled == 'true' permissions: contents: read pages: write id-token: write deployments: write - concurrency: - group: docs-release - cancel-in-progress: false - queue: max - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - env: - DRFED_RELEASE_BUILD: ${{ needs.version.outputs.build }} - PAGES_ARTIFACT: github-pages-${{ github.run_id }}-${{ github.run_attempt }} - # Nothing but Node.js is installed, and no step may run repository hooks - # or install anything else: every task runs with --no-deps and - # --skip-tools, so no project dependencies are installed either. - MISE_NO_HOOKS: "1" - MISE_AUTO_INSTALL: "false" - steps: - - uses: actions/checkout@v7.0.1 - with: - persist-credentials: false - - uses: jdx/mise-action@v5.2.6 - with: - cache: false - experimental: true - install_args: node - - name: Stop unless this release is newer than the deployed docs - id: current - run: mise run --no-deps --skip-tools docs:verify-current - env: - GH_TOKEN: ${{ github.token }} - DRFED_DOCS_URL: ${{ vars.RELEASE_DOCS_URL }} - # Outside the checkout, so that nothing downloaded can shadow its files. - - uses: actions/download-artifact@v8.0.2 - if: steps.current.outputs.current == 'true' - with: - name: docs-${{ github.run_id }} - path: ${{ runner.temp }}/docs-dist - - name: Refuse a documentation build that is not plain static files - if: steps.current.outputs.current == 'true' - run: mise run --no-deps --skip-tools docs:verify-dist "$RUNNER_TEMP/docs-dist" - - name: Record the intent to deploy this release - id: intent - if: steps.current.outputs.current == 'true' - run: mise run --no-deps --skip-tools docs:record-intent "$RUNNER_TEMP/docs-dist" - env: - GH_TOKEN: ${{ github.token }} - - uses: actions/upload-pages-artifact@v5.0.0 - if: steps.current.outputs.current == 'true' - with: - name: ${{ env.PAGES_ARTIFACT }} - path: ${{ runner.temp }}/docs-dist - retention-days: 1 - - id: deployment - if: steps.current.outputs.current == 'true' - uses: actions/deploy-pages@v5.0.1 - with: - artifact_name: ${{ env.PAGES_ARTIFACT }} - - name: Record the successful docs deployment - if: steps.current.outputs.current == 'true' - run: mise run --no-deps --skip-tools docs:record-deployment - env: - GH_TOKEN: ${{ github.token }} - DOCS_DEPLOYMENT_ID: ${{ steps.intent.outputs.deployment }} - DRFED_DOCS_URL: ${{ steps.deployment.outputs.page_url }} + uses: ./.github/workflows/docs-release.yaml + with: + build: ${{ needs.version.outputs.build }} docs-canary: - needs: [version, check, test, test-scripts, docs, publish, image-publish] + needs: [version, check, test, docs, publish] if: >- - github.event_name == 'push' && github.repository == 'fedify-dev/drfed' && - !github.event.deleted && github.ref == 'refs/heads/main' && + github.repository == 'fedify-dev/drfed' && !github.event.deleted && + github.ref == 'refs/heads/main' && needs.version.outputs.enabled == 'true' - runs-on: ubuntu-latest - timeout-minutes: 15 permissions: contents: read deployments: write - concurrency: - group: docs-canary - cancel-in-progress: false - queue: max - environment: - name: canary-docs - url: ${{ vars.CANARY_DOCS_URL }} - env: - DRFED_RELEASE_BUILD: ${{ needs.version.outputs.build }} - # Nothing but Node.js and Wrangler is installed, and no step may run - # repository hooks or install anything else: every task runs with - # --no-deps and --skip-tools, so no project dependencies are installed - # either, and the deployment step finds Wrangler already installed. - MISE_NO_HOOKS: "1" - MISE_AUTO_INSTALL: "false" - steps: - - uses: actions/checkout@v7.0.1 - with: - persist-credentials: false - # Keep the Wrangler version in sync with the docs:deploy task in - # mise.toml. If they differ, the deployment fails without installing. - - uses: jdx/mise-action@v5.2.6 - with: - cache: false - experimental: true - install_args: node npm:wrangler@4.149.0 - - name: Stop unless this build is newer than the deployed docs - id: current - run: mise run --no-deps --skip-tools docs:verify-current - env: - GH_TOKEN: ${{ github.token }} - DRFED_DOCS_URL: ${{ vars.CANARY_DOCS_URL }} - # Outside the checkout: wrangler warns about, and records, a dirty tree. - - uses: actions/download-artifact@v8.0.2 - if: steps.current.outputs.current == 'true' - with: - name: docs-${{ github.run_id }} - path: ${{ runner.temp }}/docs-dist - - name: Record the intent to deploy this build - id: intent - if: steps.current.outputs.current == 'true' - run: mise run --no-deps --skip-tools docs:record-intent "$RUNNER_TEMP/docs-dist" - env: - GH_TOKEN: ${{ github.token }} - # The task refuses anything that Cloudflare would run as code before it - # runs wrangler. Only this step receives the Cloudflare secrets, and it - # never receives a GitHub token. - - name: Deploy the documentation site to Cloudflare Pages - if: steps.current.outputs.current == 'true' - run: mise run --no-deps --skip-tools docs:deploy "$RUNNER_TEMP/docs-dist" - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - CLOUDFLARE_PROJECT_NAME: ${{ vars.CLOUDFLARE_PROJECT_NAME }} - DOCS_BRANCH: main - DOCS_COMMIT: ${{ github.sha }} - - name: Record the successful docs deployment - if: steps.current.outputs.current == 'true' - run: mise run --no-deps --skip-tools docs:record-deployment - env: - GH_TOKEN: ${{ github.token }} - DOCS_DEPLOYMENT_ID: ${{ steps.intent.outputs.deployment }} - DRFED_DOCS_URL: ${{ vars.CANARY_DOCS_URL }} + uses: ./.github/workflows/docs-canary.yaml + with: + build: ${{ needs.version.outputs.build }} + secrets: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 0000000..79854d4 --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,109 @@ +name: publish + +on: + workflow_call: + inputs: + build: + description: The calculated release build identity + required: true + type: string + +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + id-token: write + concurrency: + group: npm-publish + cancel-in-progress: false + queue: max + env: + DRFED_RELEASE_BUILD: ${{ inputs.build }} + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + - uses: jdx/mise-action@v5.2.6 + with: + cache: false + experimental: true + - uses: actions/download-artifact@v8.0.2 + with: + name: drfed-${{ github.run_id }} + path: release-artifacts + - name: Publish the verified tarballs with npm trusted publishing + env: + NPM_CONFIG_USERCONFIG: ${{ runner.temp }}/drfed-npmrc + run: mise run release:publish + + image-publish: + needs: [publish] + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + packages: write + id-token: write + attestations: write + artifact-metadata: write + concurrency: + group: npm-publish + cancel-in-progress: false + queue: max + env: + DRFED_RELEASE_BUILD: ${{ inputs.build }} + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + - uses: jdx/mise-action@v5.2.6 + with: + cache: false + experimental: true + - uses: actions/download-artifact@v8.0.2 + with: + name: drfed-image-${{ github.run_id }}-amd64 + path: image-artifacts/linux-amd64 + - uses: actions/download-artifact@v8.0.2 + with: + name: drfed-image-${{ github.run_id }}-arm64 + path: image-artifacts/linux-arm64 + - uses: actions/download-artifact@v8.0.2 + with: + name: drfed-image-index-${{ github.run_id }} + path: image-artifacts/index + # crane, gh attestation, actions/attest and @sigstore/oci all read the + # credentials this step stores. + - name: Log in to the GitHub Container Registry + run: >- + printf '%s' "$GH_TOKEN" | + docker login ghcr.io --username "$GITHUB_ACTOR" --password-stdin + - name: Push the verified image index + id: push + run: mise run release:image-push + - name: Find which copies of the provenance attestation exist + id: attestation + run: mise run release:image-attestation status + # actions/attest writes to the registry; recheck the release tag first. + - name: Check that the release tag still names this commit + if: steps.attestation.outputs.state == 'none' + run: mise run release:tag-target + - uses: actions/attest@v4.2.2 + if: steps.attestation.outputs.state == 'none' + with: + subject-name: ${{ steps.push.outputs.subject }} + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: true + - name: Attach the attestation stored on GitHub to the registry + if: steps.attestation.outputs.state == 'github-only' + run: mise run release:image-attestation attach + - name: Verify the attestation from GitHub and from the registry + run: mise run release:image-attestation verify + - name: Advance the channel tag and check anonymous pulls + run: mise run release:image-tags diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..414e642 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,113 @@ +name: test + +on: + pull_request: + # Validate branch pushes even before a PR exists; PRs also cover forks. + push: # jactionlint ignore=duplicate-triggers + branches: ["**", "!main"] + workflow_call: + inputs: + release: + description: Whether an authorized release replaces workspace image tests + required: false + type: boolean + default: false + +permissions: + contents: read + +jobs: + # Script tests that do not depend on the OS run once, outside the test matrix. + test-scripts: + name: test (scripts) + timeout-minutes: 30 + concurrency: + group: test-scripts-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + queue: max + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + - uses: jdx/mise-action@v5.2.6 + with: + cache: true + experimental: true + log_level: ${{ runner.debug == '1' && 'trace' || 'info' }} + - run: mise run test:scripts + env: + MISE_LOG_LEVEL: ${{ runner.debug == '1' && 'trace' || 'info' }} + + test: + name: test (${{ matrix.os }}, shard ${{ matrix.shard }}) + timeout-minutes: 30 + concurrency: + group: >- + test-${{ github.workflow }}-${{ github.ref }}-${{ matrix.os }}-${{ matrix.shard }} + cancel-in-progress: false + queue: max + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest-m, macos-latest, windows-latest-l] + shard: ["1/3", "2/3", "3/3"] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + - uses: jdx/mise-action@v5.2.6 + with: + cache: true + experimental: true + log_level: ${{ runner.debug == '1' && 'trace' || 'info' }} + - run: mise test --shard ${{ matrix.shard }} + env: + MISE_LOG_LEVEL: ${{ runner.debug == '1' && 'trace' || 'info' }} + LOGTAPE_TEST_MODE: ${{ runner.debug == '1' && 'always' || 'on-failure' }} + LOGTAPE_TEST_LOWEST_LEVEL: ${{ runner.debug == '1' && 'trace' || 'debug' }} + + # Non-release refs build and test the image with both engines. + image-pr: + if: ${{ !inputs.release }} + timeout-minutes: 45 + concurrency: + group: image-pr-${{ github.workflow }}-${{ github.ref }}-${{ matrix.engine }}-${{ matrix.arch }} + cancel-in-progress: false + queue: max + strategy: + fail-fast: false + matrix: + engine: [docker, podman] + arch: [amd64, arm64] + include: + - arch: amd64 + runner: ubuntu-latest + - arch: arm64 + runner: ubuntu-26.04-arm + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + - uses: jdx/mise-action@v5.2.6 + with: + cache: true + experimental: true + log_level: ${{ runner.debug == '1' && 'trace' || 'info' }} + - uses: ./.github/actions/containerd-image-store + if: matrix.engine == 'docker' + - uses: ./.github/actions/pasta-apparmor + if: matrix.engine == 'podman' + # podman compose hands the default API address to its provider. + - name: Start the Podman API socket + if: matrix.engine == 'podman' + run: systemctl --user start podman.socket + - name: Show the Podman toolchain + if: matrix.engine == 'podman' + run: podman version && podman compose version + - name: Build the image from the packed workspace + run: mise run image:build --engine "${{ matrix.engine }}" + - name: Test the exact image layout + run: mise run "test:image:${{ matrix.engine }}" --require diff --git a/.github/workflows/version.yaml b/.github/workflows/version.yaml new file mode 100644 index 0000000..72162c3 --- /dev/null +++ b/.github/workflows/version.yaml @@ -0,0 +1,40 @@ +name: version + +on: + workflow_call: + outputs: + enabled: + description: Whether this run may publish a release build + value: ${{ jobs.version.outputs.enabled }} + build: + description: The calculated release build identity + value: ${{ jobs.version.outputs.build }} + +permissions: + contents: read + +jobs: + version: + if: >- + github.event_name == 'push' && github.repository == 'fedify-dev/drfed' && + !github.event.deleted && + (github.ref == 'refs/heads/main' || github.ref_type == 'tag') + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: version-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + queue: max + outputs: + enabled: ${{ steps.version.outputs.enabled }} + build: ${{ steps.version.outputs.build }} + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + - uses: jdx/mise-action@v5.2.6 + with: + cache: false + experimental: true + - id: version + run: mise run release:version diff --git a/DEPLOY.md b/DEPLOY.md index e27217f..5c2e021 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -106,15 +106,16 @@ Current setup: GitHub Pages and Cloudflare Pages ### Jobs - - *.github/workflows/main.yaml* `docs`: builds the site on every event and - uploads it as the `docs-` artifact. It uses no secrets. - - *.github/workflows/main.yaml* `docs-release`: deploys release tags to - GitHub Pages after `github-release`. - - *.github/workflows/main.yaml* `docs-canary`: uploads `main` builds to - Cloudflare Pages as production deployments after `check`, `test`, and + - *.github/workflows/docs.yaml* `docs`: builds the site for documentation + changes and release runs, then uploads it as the `docs-` artifact. + It uses no secrets. + - *.github/workflows/docs-release.yaml* `docs-release`: deploys release tags + to GitHub Pages after `github-release`. + - *.github/workflows/docs-canary.yaml* `docs-canary`: uploads `main` builds + to Cloudflare Pages as production deployments after `check`, `test`, and `publish`. - *.github/workflows/docs-preview.yaml* `preview`: runs on `workflow_run` - after a pull request run of `main`, uploads a Cloudflare Pages preview, + after a pull request run of `docs`, uploads a Cloudflare Pages preview, and posts or updates a comment on the pull request. ### Repository settings @@ -194,7 +195,7 @@ Same-repository and fork pull requests take the same path. Cloudflare's automatic Git previews do not exist here, because the project has no Git integration. -1. The pull request run of `main` builds the site from the merge ref without +1. The pull request run of `docs` builds the site from the merge ref without secrets and uploads the artifact. A fork's run may first wait for a maintainer's approval. 2. `preview` runs from the default branch's workflow file and checks out the diff --git a/mise.toml b/mise.toml index 8aba118..945046e 100644 --- a/mise.toml +++ b/mise.toml @@ -59,7 +59,7 @@ description = "Refuse a documentation build that Cloudflare Pages would execute usage = 'arg "" help="Built documentation directory"' run = "node scripts/docs-deploy.mts verify" -# The deploy jobs in .github/workflows/main.yaml and docs-preview.yaml install +# The deploy jobs in docs-canary.yaml and docs-preview.yaml install # this Wrangler version before their secret-bearing steps; keep them in sync. [tasks."docs:deploy"] description = "Verify a documentation build and deploy it to Cloudflare Pages" diff --git a/scripts/docs-preview.mts b/scripts/docs-preview.mts index a58d02f..d1ac533 100644 --- a/scripts/docs-preview.mts +++ b/scripts/docs-preview.mts @@ -30,7 +30,7 @@ import { setTimeout as delay } from "node:timers/promises"; export const marker = ""; /** The workflow whose `docs` job uploads the documentation artifact. */ -export const buildWorkflowPath = ".github/workflows/main.yaml"; +export const buildWorkflowPath = ".github/workflows/docs.yaml"; /** The account that posts comments with the workflow's `GITHUB_TOKEN`. */ export const commentAuthor = "github-actions[bot]"; diff --git a/scripts/release.mts b/scripts/release.mts index 36d083d..f2923d7 100644 --- a/scripts/release.mts +++ b/scripts/release.mts @@ -49,7 +49,8 @@ import { packageOrder } from "./packages.mts"; const execute = promisify(execFile); const repository = "fedify-dev/drfed"; -const workflow = ".github/workflows/main.yaml"; +const releaseWorkflow = ".github/workflows/main.yaml"; +const imageAttestationWorkflow = ".github/workflows/publish.yaml"; const cliArgs = process.argv.slice(2); const command = cliArgs.shift(); function option(name: string, fallback: string): string { @@ -258,7 +259,7 @@ async function calculate(): Promise { runNumber, runId, ref, - workflow, + workflow: releaseWorkflow, }; } function buildFromEnvironment(): Version { @@ -269,7 +270,7 @@ function buildFromEnvironment(): Version { parseVersion(build.version); assert.match(build.sha, /^[a-f0-9]{40}$/u); assert.equal(build.ref, process.env.GITHUB_REF); - assert.equal(build.workflow, workflow); + assert.equal(build.workflow, releaseWorkflow); assert.equal(process.env.GITHUB_EVENT_NAME, "push"); assert.equal(process.env.GITHUB_REPOSITORY, repository); assert.equal(build.runNumber, process.env.GITHUB_RUN_NUMBER); @@ -672,7 +673,7 @@ function newerChannelBuild( } assert.equal( current?.workflow, - workflow, + releaseWorkflow, `Cannot order a foreign dev build of ${subject}`, ); const runNumber = current?.runNumber; @@ -1258,7 +1259,7 @@ function attestationConstraints(build: Version): string[] { "--repo", repository, "--signer-workflow", - `${repository}/${workflow}`, + `${repository}/${imageAttestationWorkflow}`, "--source-ref", build.ref, "--source-digest", diff --git a/scripts/test-docs-preview.mts b/scripts/test-docs-preview.mts index 3819959..42e66cf 100644 --- a/scripts/test-docs-preview.mts +++ b/scripts/test-docs-preview.mts @@ -486,7 +486,7 @@ const runId = 4242; const runPath = `/repos/${repository}/actions/runs/${runId}`; const workflowRun = { id: runId, - path: ".github/workflows/main.yaml", + path: ".github/workflows/docs.yaml", event: "pull_request", head_sha: headSha, }; diff --git a/scripts/test-release-image.mts b/scripts/test-release-image.mts index 57799d7..57d2006 100644 --- a/scripts/test-release-image.mts +++ b/scripts/test-release-image.mts @@ -47,7 +47,8 @@ const temporary = await mkdtemp(join(tmpdir(), "drfed-release-image-")); const root = join(temporary, "repo"); const archives = join(root, "release-artifacts"); const images = join(root, "image-artifacts"); -const workflow = ".github/workflows/main.yaml"; +const releaseWorkflow = ".github/workflows/main.yaml"; +const imageAttestationWorkflow = ".github/workflows/publish.yaml"; interface Build { enabled: boolean; @@ -155,7 +156,8 @@ function constraintsHold(args: string[], build: Build): boolean { const value = (name: string) => args[args.indexOf(name) + 1]; return ( value("--repo") === "fedify-dev/drfed" && - value("--signer-workflow") === `fedify-dev/drfed/${workflow}` && + value("--signer-workflow") === + `fedify-dev/drfed/${imageAttestationWorkflow}` && value("--source-ref") === build.ref && value("--source-digest") === build.sha && value("--predicate-type") === "https://slsa.dev/provenance/v1" && @@ -188,7 +190,8 @@ async function handleGh(args: string[], cwd: string) { } assert.equal(args[1], "verify"); assert.ok(constraintsHold(args, currentBuild), args.join(" ")); - const valid = (bundle: Bundle) => bundle.workflow === workflow; + const valid = (bundle: Bundle) => + bundle.workflow === imageAttestationWorkflow; if (args.includes("--bundle")) { const bundle = JSON.parse( await readFile(args[args.indexOf("--bundle") + 1]!, "utf8"), @@ -348,7 +351,7 @@ function buildOf(version: string, runNumber: string, sha: string): Build { runNumber, runId: `${runNumber}00`, ref: release ? `refs/tags/${version}` : "refs/heads/main", - workflow, + workflow: releaseWorkflow, }; } @@ -559,7 +562,10 @@ try { mediaType: "application/vnd.dev.sigstore.bundle.v0.3+json", workflow: "other.yaml", }, - { mediaType: "application/vnd.dev.sigstore.bundle.v0.3+json", workflow }, + { + mediaType: "application/vnd.dev.sigstore.bundle.v0.3+json", + workflow: imageAttestationWorkflow, + }, ]); ghFailures = 1; assert.equal( @@ -581,7 +587,7 @@ try { JSON.parse( Buffer.from((attachment.artifact as { data: number[] }).data).toString(), ).workflow, - workflow, + imageAttestationWorkflow, ); assert.equal( (await run(["image-attestation", "status"])).outputs.state, @@ -661,7 +667,10 @@ try { ); // Attaching a stored attestation also writes to the registry. github.set(released, [ - { mediaType: "application/vnd.dev.sigstore.bundle.v0.3+json", workflow }, + { + mediaType: "application/vnd.dev.sigstore.bundle.v0.3+json", + workflow: imageAttestationWorkflow, + }, ]); await assert.rejects( run(["image-attestation", "attach"]), From e8e0f8f6f600f71a4be8e9ae826bd85d90a7c715 Mon Sep 17 00:00:00 2001 From: Hyeonseo Kim Date: Wed, 4 Nov 2026 13:03:58 +0900 Subject: [PATCH 2/8] Update CONTRIBUTING.md to explain calling workflows Prompt Summary: Check grammar for the edited senetences in CONTRIBUTING.md Assisted-by: Codex:gpt-5.6-sol --- CONTRIBUTING.md | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b84ac9e..9354df4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -312,12 +312,21 @@ test, and verify packed local/global installs first; keep the exact tarballs. For each of `@drfed/models`, `@drfed/federation`, `@drfed/graphql`, `@drfed/web`, and `@drfed/drfed`, configure an npm trusted publisher for GitHub organization `fedify-dev`, repository `drfed`, and workflow filename -`main.yaml`. Enable both *Allow npm publish* and *Allow npm dist-tag*. These -are separate permissions; dist-tag access is off by default. Use GitHub-hosted -runners. The mise pin of npm 12.2.0 supports OIDC dist-tag changes and shadows -Node's bundled npm for CI publication. The packed-install harness uses Node's -bundled npm separately. No npm token is supplied to the workflow; publishing -requests provenance and uses the public repository metadata in each package. +`main.yaml`. The npm publication job is defined in +`.github/workflows/publish.yaml`, which is called as a reusable +workflow by `.github/workflows/main.yaml`. npm validates the calling workflow +for trusted publishing, so `main.yaml`, not `publish.yaml`, is intentionally +configured as the workflow filename. If `main.yaml` is renamed or another +workflow becomes the caller, update the trusted publisher configuration for all +five packages before the next publication. Renaming `publish.yaml` alone +requires updating the reusable-workflow call, but does not change the npm +trusted publisher identity. Enable both *Allow npm publish* and *Allow npm +dist-tag*. These are separate permissions; dist-tag access is off by default. +Use GitHub-hosted runners. The mise pin of npm 12.2.0 supports OIDC dist-tag +changes and shadows Node's bundled npm for CI publication. The packed-install +harness uses Node's bundled npm separately. No npm token is supplied to the +workflow; publishing requests provenance and uses the public repository +metadata in each package. The publishing job alone receives `id-token: write`; validation jobs have read access, and only the separate GitHub Release job receives `contents: write`. From 1d6f7b533777fb215480ca3f8e80e519fa996d0c Mon Sep 17 00:00:00 2001 From: Hyeonseo Kim Date: Wed, 4 Nov 2026 13:16:41 +0900 Subject: [PATCH 3/8] Publish immutable images before npm packages Push and attest the immutable GHCR image before npm publication, then move channel tags only after npm succeeds. Refuse artifact rebuilding when either the npm package version or the GHCR image version already exists. AI provenance: The contributor asked Codex to implement the first PR review finding by moving immutable image publication ahead of irreversible npm publication while keeping channel tags after npm. Codex traced the release and recovery workflows, split the reusable publishing workflow into image-push, publish, and image-tags jobs, added authenticated GHCR checks to artifact recovery, and added a fixture for existing image versions. The contributor reviewed how the recovery changes followed from the review comment and decided to implement the complete ordering and fail-closed behavior. The result was verified with mise run check, mise run check:release, mise run test:release-image, and git diff --check. Changelog: none Assisted-by: Codex:gpt-5.6-sol --- .github/workflows/artifacts.yaml | 16 +++++- .github/workflows/main.yaml | 1 + .github/workflows/publish.yaml | 93 +++++++++++++++++++++----------- CONTRIBUTING.md | 65 +++++++++++----------- mise.toml | 1 + scripts/release.mts | 9 ++++ scripts/test-release.mts | 22 ++++++-- 7 files changed, 141 insertions(+), 66 deletions(-) diff --git a/.github/workflows/artifacts.yaml b/.github/workflows/artifacts.yaml index bdca47c..0a32dee 100644 --- a/.github/workflows/artifacts.yaml +++ b/.github/workflows/artifacts.yaml @@ -18,6 +18,7 @@ jobs: permissions: contents: read actions: read + packages: read concurrency: group: artifacts-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false @@ -33,6 +34,12 @@ jobs: with: cache: false experimental: true + - name: Log in to the GitHub Container Registry + env: + GH_TOKEN: ${{ github.token }} + run: >- + printf '%s' "$GH_TOKEN" | + docker login ghcr.io --username "$GITHUB_ACTOR" --password-stdin - name: Find the original verified artifacts from this run id: original env: @@ -74,6 +81,7 @@ jobs: permissions: contents: read actions: read + packages: read concurrency: group: image-${{ github.workflow }}-${{ github.ref }}-${{ matrix.arch }} cancel-in-progress: false @@ -98,8 +106,14 @@ jobs: with: cache: false experimental: true + - name: Log in to the GitHub Container Registry + env: + GH_TOKEN: ${{ github.token }} + run: >- + printf '%s' "$GH_TOKEN" | + docker login ghcr.io --username "$GITHUB_ACTOR" --password-stdin # A rerun keeps the layout that was tested first; a rebuild would not - # reproduce its digest. Without it, a version already on npm must not get + # reproduce its digest. A version already in either registry must not get # new image bytes. - name: Find the original image layout from this run id: original diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index c06e768..bc31d31 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -32,6 +32,7 @@ jobs: permissions: contents: read actions: read + packages: read uses: ./.github/workflows/artifacts.yaml with: build: ${{ needs.version.outputs.build }} diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 79854d4..7be4e88 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -12,37 +12,7 @@ permissions: contents: read jobs: - publish: - runs-on: ubuntu-latest - timeout-minutes: 20 - permissions: - contents: read - id-token: write - concurrency: - group: npm-publish - cancel-in-progress: false - queue: max - env: - DRFED_RELEASE_BUILD: ${{ inputs.build }} - steps: - - uses: actions/checkout@v7.0.1 - with: - persist-credentials: false - - uses: jdx/mise-action@v5.2.6 - with: - cache: false - experimental: true - - uses: actions/download-artifact@v8.0.2 - with: - name: drfed-${{ github.run_id }} - path: release-artifacts - - name: Publish the verified tarballs with npm trusted publishing - env: - NPM_CONFIG_USERCONFIG: ${{ runner.temp }}/drfed-npmrc - run: mise run release:publish - - image-publish: - needs: [publish] + image-push: runs-on: ubuntu-latest timeout-minutes: 20 permissions: @@ -105,5 +75,66 @@ jobs: run: mise run release:image-attestation attach - name: Verify the attestation from GitHub and from the registry run: mise run release:image-attestation verify + + publish: + needs: [image-push] + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + id-token: write + concurrency: + group: npm-publish + cancel-in-progress: false + queue: max + env: + DRFED_RELEASE_BUILD: ${{ inputs.build }} + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + - uses: jdx/mise-action@v5.2.6 + with: + cache: false + experimental: true + - uses: actions/download-artifact@v8.0.2 + with: + name: drfed-${{ github.run_id }} + path: release-artifacts + - name: Publish the verified tarballs with npm trusted publishing + env: + NPM_CONFIG_USERCONFIG: ${{ runner.temp }}/drfed-npmrc + run: mise run release:publish + + image-tags: + needs: [publish] + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + packages: write + concurrency: + group: npm-publish + cancel-in-progress: false + queue: max + env: + DRFED_RELEASE_BUILD: ${{ inputs.build }} + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + - uses: jdx/mise-action@v5.2.6 + with: + cache: false + experimental: true + - uses: actions/download-artifact@v8.0.2 + with: + name: drfed-image-index-${{ github.run_id }} + path: image-artifacts/index + - name: Log in to the GitHub Container Registry + run: >- + printf '%s' "$GH_TOKEN" | + docker login ghcr.io --username "$GITHUB_ACTOR" --password-stdin - name: Advance the channel tag and check anonymous pulls run: mise run release:image-tags diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9354df4..23a8f12 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -328,9 +328,11 @@ harness uses Node's bundled npm separately. No npm token is supplied to the workflow; publishing requests provenance and uses the public repository metadata in each package. -The publishing job alone receives `id-token: write`; validation jobs have read -access, and only the separate GitHub Release job receives `contents: write`. -Npm publication and GitHub Release jobs share one concurrency group +Within the release publication chain, only the npm publication and image push +jobs receive `id-token: write`; validation jobs have read access, and only the +separate GitHub Release job receives `contents: write`. Immutable image +publication, npm publication, image channel updates, and GitHub Release jobs +share one concurrency group (`queue: max`) with cancellation disabled. The queue has GitHub's 100-pending-job limit; rerun any job cancelled because it was full. Serialization prevents interleaving. If an older build finishes late, the @@ -357,11 +359,12 @@ metadata is not visible yet, CI waits up to five minutes to verify it. Successful npm uploads and dist-tag changes need no read-after-write confirmation. -If the original artifact is missing and any package in the set is already -published, recovery fails closed. Push a new commit to `main` to produce a new -dev build, or prepare a new reviewed patch tag. Do not rebuild an existing -immutable version. Immediately before registry writes and GitHub Release -writes, CI checks again whether the release tag has been deleted or moved. +If the original artifact is missing and any package in the set or the immutable +image version is already published, recovery fails closed. Push a new commit +to `main` to produce a new dev build, or prepare a new reviewed patch tag. Do +not rebuild an existing immutable version. Immediately before registry writes +and GitHub Release writes, CI checks again whether the release tag has been +deleted or moved. After npm publication, tag builds create or verify the GitHub Release from `CHANGES.md` and upload the same verified tarballs. Reruns keep an existing @@ -425,29 +428,29 @@ In CI, pull requests build the image with Docker and with Podman on that built it, without uploading anything. Release builds use Docker and test the same layout with Docker, then Podman. Release builds keep each tested layout as a `drfed-image--` artifact and merge them into one OCI -image index, kept as `drfed-image-index-`. The npm `publish` job -requires that index, so a broken image blocks the whole release set. The -`image-publish` job then runs in the `npm-publish` concurrency group: - - - It pushes the index as the immutable `` tag. An existing tag with - the same digest is skipped; a different digest fails the run. - - It attests the index digest with `actions/attest` and requires the - attestation to verify both from GitHub and from the registry. If a - previous attempt stored it on GitHub only, the job attaches that stored - bundle instead of creating another one. - - It moves the `dev`, `latest`, or `next` channel tag only forward, using - the `org.drfed.build` annotation with the same ordering rules as npm. - - It finally resolves the channel tag without credentials. If the package - is private, `latest` and `next` builds fail and `dev` builds only warn. - -To recover from a failed image publication after npm publication succeeded, -rerun the same workflow run; it reuses every artifact and pushes identical -bytes. If the artifacts have expired, prepare a new patch release instead of -rebuilding a published version; the `image` job refuses to build a missing -layout again once the version is on npm. Every registry write, including -attaching an attestation, first checks that the release tag still names the -built commit. `mise run test:release-image` exercises these -rules with stub `crane` and `gh` commands. +image index, kept as `drfed-image-index-`. The publication workflow +requires that index, so a broken image blocks the whole release set. Its jobs +run in the `npm-publish` concurrency group in this order: + + - `image-push` pushes the index as the immutable `` tag, then attests + its digest with `actions/attest` and verifies the attestation from GitHub + and the registry. A matching tag is reused, a conflicting digest fails, + and a GitHub-only attestation is attached instead of recreated. + - `publish` publishes the npm package set only after `image-push` succeeds. + - `image-tags` moves the `dev`, `latest`, or `next` channel tag only after npm + publication, using the `org.drfed.build` annotation with the same ordering + rules as npm. It then resolves the channel without credentials. If the + package is private, `latest` and `next` builds fail and `dev` builds only + warn. + +If immutable image publication or attestation fails, npm publication does not +start. If npm publication or the later channel update fails, rerun the same +workflow run; it reuses every artifact and the matching immutable image. If +the artifacts have expired after an immutable npm or image write, prepare a new +patch release instead of rebuilding that version. Every registry write, +including attaching an attestation, first checks that the release tag still +names the built commit. `mise run test:release-image` exercises these rules +with stub `crane` and `gh` commands. After the first `main` build publishes the package, a maintainer must check its visibility in the organization's package settings. If it is private, make it diff --git a/mise.toml b/mise.toml index 945046e..a9453ba 100644 --- a/mise.toml +++ b/mise.toml @@ -481,6 +481,7 @@ run = "node scripts/release.mts tag-target" [tasks."release:ensure-unpublished"] description = "CI release ensure-unpublished step" +tools = { "aqua:google/go-containerregistry" = "0.22.1" } run = "node scripts/release.mts ensure-unpublished" [tasks."release:publish"] diff --git a/scripts/release.mts b/scripts/release.mts index f2923d7..4cacca9 100644 --- a/scripts/release.mts +++ b/scripts/release.mts @@ -540,6 +540,15 @@ async function ensureUnpublished(): Promise { "Original artifact missing for a published version; do not rebuild it", ); } + const existingImage = await crane([ + "digest", + `${imageRepository}:${build.version}`, + ]); + assert.equal( + existingImage, + null, + "Original artifact missing for a published image version; do not rebuild it", + ); } async function published(entry: Artifact, build: Version): Promise { const document = await get( diff --git a/scripts/test-release.mts b/scripts/test-release.mts index d391952..b7c79bf 100644 --- a/scripts/test-release.mts +++ b/scripts/test-release.mts @@ -49,6 +49,21 @@ const env = Object.fromEntries( ), ), ); +const bin = join(temporary, "bin"); +await mkdir(bin); +await writeFile( + join(bin, "crane"), + `#!/usr/bin/env node +if (process.env.DRFED_TEST_IMAGE_EXISTS === "true") { + console.log("sha256:${"a".repeat(64)}"); +} else { + console.error("MANIFEST_UNKNOWN: manifest unknown"); + process.exitCode = 1; +} +`, + { mode: 0o755 }, +); +env.PATH = `${bin}${delimiter}${env.PATH}`; const documents = new Map>(); const tags = new Map>(); const visibilityMisses = new Map(); @@ -657,6 +672,10 @@ try { } env.DRFED_RELEASE_BUILD = JSON.stringify(build); await run(["ensure-unpublished"]); + await assert.rejects( + run(["ensure-unpublished"], { DRFED_TEST_IMAGE_EXISTS: "true" }), + /published image version/u, + ); await run(["apply"]); await run(["pack"]); await run(["verify"]); @@ -664,11 +683,8 @@ try { await readFile(join(archives, "manifest.json"), "utf8"), ); // A stub npm lets the real publisher exercise registry and failure behavior. - const bin = join(temporary, "bin"); - await mkdir(bin); const stub = `#!/usr/bin/env node\nimport {readFileSync} from 'node:fs';import {execFileSync} from 'node:child_process';import {createHash} from 'node:crypto';const args=process.argv.slice(2);const body={args};if(args[0]==='publish'){body.manifest=JSON.parse(execFileSync('tar',['-xOf',args[1],'package/package.json'],{encoding:'utf8'}));body.integrity='sha512-'+createHash('sha512').update(readFileSync(args[1])).digest('base64');}const response=await fetch('${registry}__npm',{method:'POST',body:JSON.stringify(body)});if(!response.ok){console.error(await response.text());process.exitCode=1;}\n`; await writeFile(join(bin, "npm"), stub, { mode: 0o755 }); - env.PATH = `${bin}${delimiter}${env.PATH}`; publishFailure = "E503 fixture failure"; await assert.rejects(run(["publish"])); assert.equal(operations.length, 3); From b8089a9d265563fe0f75e5768c4eef630504ede7 Mon Sep 17 00:00:00 2001 From: Hyeonseo Kim Date: Wed, 4 Nov 2026 13:19:56 +0900 Subject: [PATCH 4/8] Add docs workflows to publish needs Co-authored-by: Hong Minhee --- .github/workflows/main.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index bc31d31..2274111 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -38,7 +38,7 @@ jobs: build: ${{ needs.version.outputs.build }} publish: - needs: [version, artifacts] + needs: [version, artifacts, docs] if: >- github.repository == 'fedify-dev/drfed' && !github.event.deleted && needs.version.outputs.enabled == 'true' From 16f2ee404e29386e90fd94daa66fba22e9fc5e59 Mon Sep 17 00:00:00 2001 From: Hyeonseo Kim Date: Wed, 4 Nov 2026 13:25:19 +0900 Subject: [PATCH 5/8] Remove Paths filter in docs.yaml Co-authored-by: Hong Minhee --- .github/workflows/docs.yaml | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 5e20f8d..ad346e7 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -2,29 +2,9 @@ name: docs on: pull_request: - paths: - - .github/workflows/docs.yaml - - CHANGES.md - - docker/** - - docs/** - - mise.toml - - package.json - - pnpm-lock.yaml - - pnpm-workspace.yaml - - scripts/images/** # Validate branch pushes even before a PR exists; PRs also cover forks. push: # jactionlint ignore=duplicate-triggers branches: ["**", "!main"] - paths: - - .github/workflows/docs.yaml - - CHANGES.md - - docker/** - - docs/** - - mise.toml - - package.json - - pnpm-lock.yaml - - pnpm-workspace.yaml - - scripts/images/** workflow_call: inputs: build: From 853a7c512cf60b3e279b4254bbb647472d9a1fb7 Mon Sep 17 00:00:00 2001 From: Hyeonseo Kim Date: Wed, 4 Nov 2026 13:27:19 +0900 Subject: [PATCH 6/8] Updatge CONTRIBUTING.md to explain concurrency Co-authored-by: Hong Minhee --- CONTRIBUTING.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 23a8f12..bd86b00 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -328,6 +328,11 @@ harness uses Node's bundled npm separately. No npm token is supplied to the workflow; publishing requests provenance and uses the public repository metadata in each package. +The `main` caller separately serializes complete release-capable runs per ref +with cancellation disabled and `queue: max`, so that no run is cancelled or +replaced while waiting. Its `-caller` suffix keeps this group distinct from +the concurrency groups inside called reusable workflows. + Within the release publication chain, only the npm publication and image push jobs receive `id-token: write`; validation jobs have read access, and only the separate GitHub Release job receives `contents: write`. Immutable image From 9e18e7cb473d5502cbb47769d818f4be2280059e Mon Sep 17 00:00:00 2001 From: Hyeonseo Kim Date: Thu, 5 Nov 2026 16:11:52 +0900 Subject: [PATCH 7/8] Fix typo in Documentation --- CONTRIBUTING.md | 12 ++++++------ DEPLOY.md | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bd86b00..14ae6c6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -312,13 +312,13 @@ test, and verify packed local/global installs first; keep the exact tarballs. For each of `@drfed/models`, `@drfed/federation`, `@drfed/graphql`, `@drfed/web`, and `@drfed/drfed`, configure an npm trusted publisher for GitHub organization `fedify-dev`, repository `drfed`, and workflow filename -`main.yaml`. The npm publication job is defined in -`.github/workflows/publish.yaml`, which is called as a reusable -workflow by `.github/workflows/main.yaml`. npm validates the calling workflow -for trusted publishing, so `main.yaml`, not `publish.yaml`, is intentionally -configured as the workflow filename. If `main.yaml` is renamed or another +*main.yaml*. The npm publication job is defined in +*.github/workflows/publish.yaml*, which is called as a reusable +workflow by *.github/workflows/main.yaml*. npm validates the calling workflow +for trusted publishing, so *main.yaml*, not *publish.yaml*, is intentionally +configured as the workflow filename. If *main.yaml* is renamed or another workflow becomes the caller, update the trusted publisher configuration for all -five packages before the next publication. Renaming `publish.yaml` alone +five packages before the next publication. Renaming *publish.yaml* alone requires updating the reusable-workflow call, but does not change the npm trusted publisher identity. Enable both *Allow npm publish* and *Allow npm dist-tag*. These are separate permissions; dist-tag access is off by default. diff --git a/DEPLOY.md b/DEPLOY.md index 5c2e021..13bb449 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -106,9 +106,9 @@ Current setup: GitHub Pages and Cloudflare Pages ### Jobs - - *.github/workflows/docs.yaml* `docs`: builds the site for documentation - changes and release runs, then uploads it as the `docs-` artifact. - It uses no secrets. + - *.github/workflows/docs.yaml* `docs`: builds the site for every pull request + and non-`main` branch push, and for release runs, then uploads it as the + `docs-` artifact. It uses no secrets. - *.github/workflows/docs-release.yaml* `docs-release`: deploys release tags to GitHub Pages after `github-release`. - *.github/workflows/docs-canary.yaml* `docs-canary`: uploads `main` builds From 5a5e6611cfdad0e2a0ed8f2926a112ccf8ea786e Mon Sep 17 00:00:00 2001 From: Hyeonseo Kim Date: Thu, 5 Nov 2026 16:16:47 +0900 Subject: [PATCH 8/8] Reuse a composite action for GHCR login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace four duplicated GHCR login steps with one local composite action while preserving each job’s package permissions and token scope. AI provenance: The contributor asked Codex to implement the third unresolved review comment by consolidating the repeated GHCR login steps. Codex inspected the existing local action pattern, extracted the login command behind an explicit token input, and updated all four callers without changing their permissions or ordering. The contributor reviewed the factoring and confirmed that the responsibilities were separated correctly. The result was verified with mise run check:workflows, mise run check, and git diff --check. Changelog: none Assisted-by: Codex:gpt-5.6-sol --- .github/actions/ghcr-login/action.yaml | 16 ++++++++++++++++ .github/workflows/artifacts.yaml | 18 ++++++------------ .github/workflows/publish.yaml | 16 +++++++--------- 3 files changed, 29 insertions(+), 21 deletions(-) create mode 100644 .github/actions/ghcr-login/action.yaml diff --git a/.github/actions/ghcr-login/action.yaml b/.github/actions/ghcr-login/action.yaml new file mode 100644 index 0000000..0142213 --- /dev/null +++ b/.github/actions/ghcr-login/action.yaml @@ -0,0 +1,16 @@ +name: Log in to the GitHub Container Registry +description: Authenticate Docker-compatible tools to GHCR. +inputs: + token: + description: A GitHub token with package access + required: true +runs: + using: composite + steps: + - name: Log in to the GitHub Container Registry + shell: bash + env: + GH_TOKEN: ${{ inputs.token }} + run: >- + printf '%s' "$GH_TOKEN" | + docker login ghcr.io --username "$GITHUB_ACTOR" --password-stdin diff --git a/.github/workflows/artifacts.yaml b/.github/workflows/artifacts.yaml index 0a32dee..dbef080 100644 --- a/.github/workflows/artifacts.yaml +++ b/.github/workflows/artifacts.yaml @@ -34,12 +34,9 @@ jobs: with: cache: false experimental: true - - name: Log in to the GitHub Container Registry - env: - GH_TOKEN: ${{ github.token }} - run: >- - printf '%s' "$GH_TOKEN" | - docker login ghcr.io --username "$GITHUB_ACTOR" --password-stdin + - uses: ./.github/actions/ghcr-login + with: + token: ${{ github.token }} - name: Find the original verified artifacts from this run id: original env: @@ -106,12 +103,9 @@ jobs: with: cache: false experimental: true - - name: Log in to the GitHub Container Registry - env: - GH_TOKEN: ${{ github.token }} - run: >- - printf '%s' "$GH_TOKEN" | - docker login ghcr.io --username "$GITHUB_ACTOR" --password-stdin + - uses: ./.github/actions/ghcr-login + with: + token: ${{ github.token }} # A rerun keeps the layout that was tested first; a rebuild would not # reproduce its digest. A version already in either registry must not get # new image bytes. diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 7be4e88..4176e86 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -49,11 +49,10 @@ jobs: name: drfed-image-index-${{ github.run_id }} path: image-artifacts/index # crane, gh attestation, actions/attest and @sigstore/oci all read the - # credentials this step stores. - - name: Log in to the GitHub Container Registry - run: >- - printf '%s' "$GH_TOKEN" | - docker login ghcr.io --username "$GITHUB_ACTOR" --password-stdin + # credentials this action stores. + - uses: ./.github/actions/ghcr-login + with: + token: ${{ github.token }} - name: Push the verified image index id: push run: mise run release:image-push @@ -132,9 +131,8 @@ jobs: with: name: drfed-image-index-${{ github.run_id }} path: image-artifacts/index - - name: Log in to the GitHub Container Registry - run: >- - printf '%s' "$GH_TOKEN" | - docker login ghcr.io --username "$GITHUB_ACTOR" --password-stdin + - uses: ./.github/actions/ghcr-login + with: + token: ${{ github.token }} - name: Advance the channel tag and check anonymous pulls run: mise run release:image-tags