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 new file mode 100644 index 0000000..dbef080 --- /dev/null +++ b/.github/workflows/artifacts.yaml @@ -0,0 +1,218 @@ +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 + packages: 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 + - uses: ./.github/actions/ghcr-login + with: + token: ${{ github.token }} + - 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 + packages: 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 + - 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. + - 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..ad346e7 --- /dev/null +++ b/.github/workflows/docs.yaml @@ -0,0 +1,67 @@ +name: docs + +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: + 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..2274111 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -1,709 +1,101 @@ 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 + packages: read + uses: ./.github/workflows/artifacts.yaml + with: + build: ${{ needs.version.outputs.build }} publish: - needs: [version, artifacts, image-index] + needs: [version, artifacts, docs] 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..4176e86 --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,138 @@ +name: publish + +on: + workflow_call: + inputs: + build: + description: The calculated release build identity + required: true + type: string + +permissions: + contents: read + +jobs: + image-push: + 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 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 + - 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 + + 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 + - uses: ./.github/actions/ghcr-login + with: + token: ${{ github.token }} + - 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/CONTRIBUTING.md b/CONTRIBUTING.md index b84ac9e..14ae6c6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -312,16 +312,32 @@ 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. - -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 +*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 `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 +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 @@ -348,11 +364,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 @@ -416,29 +433,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/DEPLOY.md b/DEPLOY.md index e27217f..13bb449 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 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 + 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..a9453ba 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" @@ -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/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..4cacca9 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); @@ -539,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( @@ -672,7 +682,7 @@ function newerChannelBuild( } assert.equal( current?.workflow, - workflow, + releaseWorkflow, `Cannot order a foreign dev build of ${subject}`, ); const runNumber = current?.runNumber; @@ -1258,7 +1268,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"]), 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);