From eed9c319d31e00dfbdafa47acff52ef3c6964c70 Mon Sep 17 00:00:00 2001 From: Alexey Zhokhov Date: Tue, 22 Sep 2026 00:25:00 +0700 Subject: [PATCH 1/2] chore(ci): regenerate velnor-workflow tree at schema 2 (wave pin 4dec6b9e) Degenerate s1 (4fa7a3a8, orphan docs unit) to full schema-2 tree at the wave pin: hosted-only singleton, ci-pr/ci-main/ci-policy/nightly/ maintenance, [release] kind=apt via declare row (holla values), Policy in declared required checks. Deletes obsolete NO_WORKFLOWS_REQUIRED.md; root APT source configs (conf/, holla.gpg, package-state.json, renovate.json) unchanged. [renovate] deferred: s2 validation requires the velnor provider for the writer, which the visibility policy forbids on public repos (W4 escalation); re-add once the generator admits a hosted writer. Note: the feed cannot verify/publish until the source repo adopts the package-release contract (signer workflow + attestations + velnor-style coherence assets); release.yml is fail-closed until then. Signed-off-by: Alexey Zhokhov --- .github-gen/NO_WORKFLOWS_REQUIRED.md | 18 - .github-gen/velnor-workflow.toml | 58 ++- .github-gen/visibility.toml | 2 + .github/AGENTS.md | 5 + .github/CLAUDE.md | 1 + .github/actionlint.yaml | 2 - .github/ci/.github-actions-generator-state | 20 +- .github/ci/project.toml | 38 +- .github/workflows/ci-main.yml | 401 +++++++++++++++++++++ .github/workflows/ci-policy.yml | 126 +++++++ .github/workflows/ci-pr.yml | 285 +++++++++++++++ .github/workflows/ci-unit-docs.yml | 245 +++---------- .github/workflows/maintenance.yml | 352 ++++++++++++++++++ .github/workflows/nightly.yml | 103 ++++++ .github/workflows/release.yml | 302 ++++++++++++++++ 15 files changed, 1714 insertions(+), 244 deletions(-) delete mode 100644 .github-gen/NO_WORKFLOWS_REQUIRED.md create mode 100644 .github-gen/visibility.toml create mode 100644 .github/AGENTS.md create mode 120000 .github/CLAUDE.md create mode 100644 .github/workflows/ci-main.yml create mode 100644 .github/workflows/ci-policy.yml create mode 100644 .github/workflows/ci-pr.yml create mode 100644 .github/workflows/maintenance.yml create mode 100644 .github/workflows/nightly.yml create mode 100644 .github/workflows/release.yml diff --git a/.github-gen/NO_WORKFLOWS_REQUIRED.md b/.github-gen/NO_WORKFLOWS_REQUIRED.md deleted file mode 100644 index 3172bb8..0000000 --- a/.github-gen/NO_WORKFLOWS_REQUIRED.md +++ /dev/null @@ -1,18 +0,0 @@ -# NO_WORKFLOWS_REQUIRED - -Clean-room regeneration at velnor @ e05aee6: `apt-repository` is a descriptive -profile label only. velnor-workflow has no Class-A/B primitive for APT feed CI -(ci-apt, publish, package-update, package-updater, renovate, composite actions). - -Classification: **C** (`migrations/generic-workflow-generator/capability-matrix.md` -— "APT feed update"). - -Typed config produces `ci-unit-docs.yml` only. APT workflows are intentionally -omitted until the generator gains apt-repository primitives (signed reprepro + -Pages publish, package channel updater). - -Omitted: `ci.yml`, `ci-apt.yml`, `publish.yml`, `package-update.yml`, -`package-updater.yml`, `renovate.yml`, and composite actions (`aggregate`, -`cache-contract`, `run-gate`). - -No legacy workflow YAML was copied during this migration. diff --git a/.github-gen/velnor-workflow.toml b/.github-gen/velnor-workflow.toml index 7633c65..f8eb632 100644 --- a/.github-gen/velnor-workflow.toml +++ b/.github-gen/velnor-workflow.toml @@ -1,21 +1,57 @@ -schema = 1 +schema = 2 [generator] repository = "tailrocks/holla-apt" -revision = "4fa7a3a85f141a6bb95bc9bdf0eef9e3ddde165d" +# D19 pin: the velnor-workflow BUILD commit whose product rendered this tree. +revision = "4dec6b9ec28b0d51cb370fd8f5d5401c6186adf0" + +[workflow] +providers = ["github-hosted"] +automatic_providers = ["github-hosted"] +default_branch = "main" +profile = "apt-repository" + +[workflow.selectors.github-hosted] +runs_on = ["ubuntu-24.04"] + [policy] -ruleset_required_status_checks = ["ci-required"] +ruleset_required_status_checks = ["ci-required", "Policy"] ruleset_external_status_checks = ["DCO"] +# [renovate] DEFERRED (W4 escalation): s2 `validate_renovate` +# (s2/config/mod.rs) requires the velnor provider + +# [workflow.selectors.velnor], but `enforce_visibility_policy` (s2/mod.rs) +# requires public repos to be exactly [github-hosted] and rejects a velnor +# selector — so [renovate] is unsatisfiable on public repos at this pin. +# The renderer itself is provider-agnostic (`control_plane_runner`), so the +# fix is validation-only (generator side). Re-add with [[declare]] +# renovate + renovate-validate rows once the generator admits a hosted +# writer for public repos. +# [renovate] +# enabled = true +# reason = "Scheduled dependency PRs per renovate.json. ..." -[workflow] -profile = "apt-repository" -runners = "both" -automatic = "both" -github_runner = "ubuntu-24.04" -velnor_labels = ["self-hosted", "velnor-target-mvp"] -default_branch = "main" -files = ["ci-unit-docs.yml"] +[release] +enabled = true +reason = "APT feed publisher: verify-before-mutation feed workflow for the holla Debian repository." +kind = "apt" +package = "holla" +binary = "holla" +source_repository = "tailrocks/holla" +consumer_repository = "tailrocks/holla-apt" +manifest_schema = "velnor.package-release.v1" +signer_fingerprint = "925B7B4B807283B2391DEF2DE5BC87724E0F3E0A" +passphrase_secret = "APT_GPG_PASSPHRASE" +signing_key_secret = "APT_GPG_PRIVATE_KEY" +keyring_path = "holla.gpg" +apt_origin = "Holla" +apt_feed_url = "https://holla-apt.tailrocks.com" +description = "apt repository for holla - adaptive dev environment CLI" +retention = 1 + +[[declare]] +primitive = "release" +file = "release.yml" [[units]] id = "docs" diff --git a/.github-gen/visibility.toml b/.github-gen/visibility.toml new file mode 100644 index 0000000..e88f545 --- /dev/null +++ b/.github-gen/visibility.toml @@ -0,0 +1,2 @@ +repository = "tailrocks/holla-apt" +visibility = "public" diff --git a/.github/AGENTS.md b/.github/AGENTS.md new file mode 100644 index 0000000..729b02f --- /dev/null +++ b/.github/AGENTS.md @@ -0,0 +1,5 @@ +# Generated files + +Everything under `.github` is generated by [velnor-workflow](https://github.com/tailrocks/velnor/tree/main/crates/velnor-workflow). + +Never hand-edit this directory. Changes to generated behavior require a Velnor PR: first research, analyze, and independently verify a generic solution, never a repository-specific workaround. Keep generation inputs outside `.github`, then regenerate. Root `AGENTS.md` rules still apply. diff --git a/.github/CLAUDE.md b/.github/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/.github/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml index 62587bd..c2cd198 100644 --- a/.github/actionlint.yaml +++ b/.github/actionlint.yaml @@ -4,6 +4,4 @@ self-hosted-runner: labels: - - self-hosted - ubuntu-24.04 - - velnor-target-mvp diff --git a/.github/ci/.github-actions-generator-state b/.github/ci/.github-actions-generator-state index 4e6159a..77b4dd9 100644 --- a/.github/ci/.github-actions-generator-state +++ b/.github/ci/.github-actions-generator-state @@ -1,11 +1,19 @@ # Generated ownership state; do not edit. schema = 2 [inputs] -config ce9ef35ac6d76a3d -scan a263362d4f301684 -generator 54 +config 492fb19e326d7a3a +scan 91be9c9948367bd2 +generator 69 [outputs] -.github/actionlint.yaml ec35f48ae7e90e0e -.github/ci/project.toml a0a5c1624ba0e68f -.github/workflows/ci-unit-docs.yml a15245ae1f15dd70 +.github/AGENTS.md 02ebb7198c435e9b +.github/actionlint.yaml c642e98fce5b1906 +.github/ci/project.toml 0c6676915893c403 +.github/workflows/ci-main.yml bfc71c80ba609e3a +.github/workflows/ci-policy.yml 6ed3b0156220a772 +.github/workflows/ci-pr.yml 878bc076414eeec5 +.github/workflows/ci-unit-docs.yml fa2dd72e726a8525 +.github/workflows/maintenance.yml 40515e95a46d5259 +.github/workflows/nightly.yml 0659fbb3d6af2359 +.github/workflows/release.yml 7eb1a5edd73ae668 config/fleet/velnor-host.env d14b419216449423 +.github/CLAUDE.md 78a740c6ccf6937a diff --git a/.github/ci/project.toml b/.github/ci/project.toml index 93911e4..280e1eb 100644 --- a/.github/ci/project.toml +++ b/.github/ci/project.toml @@ -1,10 +1,12 @@ # Generated by velnor-workflow. Regenerate; do not hand-edit. -schema = 2 +schema = 3 repository = "tailrocks/holla-apt" profile = "apt-repository" verified = true default_branch = "main" -runners = "both" +providers = ["github-hosted"] +automatic_providers = ["github-hosted"] +default_dispatch_providers = ["github-hosted"] [analysis] method = "static-filesystem-and-manifest-inspection" @@ -12,13 +14,19 @@ detected = ["renovate-configuration"] limitations = ["Project code, build scripts, task runners, and commands are never executed during analysis.", "Release, signing, registry, deployment, branch-protection, and runner-capability contracts remain explicit manual inputs.", "Renovate credentials, runner placement, and write permissions cannot be inferred from repository files."] [workflow] -github_runner = "ubuntu-24.04" -velnor_labels = ["self-hosted", "velnor-target-mvp"] -files = ["ci-unit-docs.yml"] +files = ["ci-main.yml", "ci-policy.yml", "ci-pr.yml", "ci-unit-docs.yml", "maintenance.yml", "nightly.yml", "release.yml"] [release] -enabled = false -reason = "Release is fail-closed. Enable only after declaring immutable artifact, registry, provenance, and tag-protection policy." +enabled = true +reason = "APT feed publisher: verify-before-mutation feed workflow for the holla Debian repository." +kind = "apt" +package = "holla" +packages = [] +binary = "holla" +targets = [] +source_repository = "tailrocks/holla" +consumer_repository = "tailrocks/holla-apt" +description = "apt repository for holla - adaptive dev environment CLI" [[unit]] id = "docs" @@ -26,7 +34,15 @@ label = "Documentation" kind = "docs" root = "." watch = [] -github_pr_commands = [] -github_full_commands = [] -velnor_pr_commands = [] -velnor_full_commands = [] +pr_commands = [] +full_commands = [] +platform = "linux-x64" +trust = "untrusted-ok" +[unit.capabilities] +docker = false +nested_privileged_docker = false +buildx_compose = false +testcontainers = false +services_with_readiness = false +browser_binaries = false +native_macos_arm64 = false diff --git a/.github/workflows/ci-main.yml b/.github/workflows/ci-main.yml new file mode 100644 index 0000000..e8a4730 --- /dev/null +++ b/.github/workflows/ci-main.yml @@ -0,0 +1,401 @@ +# Generated by velnor-workflow. Regenerate; do not hand-edit. +name: CI / Main +run-name: CI / main · ${{ github.event_name }} · ${{ github.ref_name }} + +on: + push: + branches: [main] + workflow_dispatch: + inputs: + scope: + description: Verification scope + required: true + default: full + type: choice + options: + - affected + - full + base_sha: + description: Git ref or SHA used as the affected-selection base + required: false + default: refs/heads/main + type: string + + +concurrency: + group: ci-${{ github.workflow }}-${{ github.run_id }} + cancel-in-progress: false + +permissions: + actions: read + contents: read + +jobs: + plan: + name: "Control / Planning" + runs-on: ubuntu-24.04 + outputs: + scope: ${{ steps.plan.outputs.scope }} + base_sha: ${{ steps.plan.outputs.base_sha }} + head_sha: ${{ steps.plan.outputs.head_sha }} + units: ${{ steps.plan.outputs.units }} + unit_ids: ${{ steps.plan.outputs.unit_ids }} + full_units: ${{ steps.plan.outputs.full_units }} + plan_digest: ${{ steps.plan.outputs.plan_digest }} + excluded: ${{ steps.plan.outputs.excluded }} + docs_matrix: ${{ steps.plan.outputs.docs_matrix }} + # Presence-only no-work marker: `planned_no_work` is `true` or absent, + # never `false`. Consumers MUST branch + # `needs.plan.outputs.planned_no_work == 'true'` for the no-work path; + # any other value (including absent) means the plan selected work. + planned_no_work: ${{ steps.plan.outputs.planned_no_work }} + no_work_reason: ${{ steps.plan.outputs.no_work_reason }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Set up Velnor workflow runtime + id: runtime + uses: tailrocks/velnor/.github/actions/setup-velnor-workflow@4dec6b9ec28b0d51cb370fd8f5d5401c6186adf0 + with: + rev: 4dec6b9ec28b0d51cb370fd8f5d5401c6186adf0 + - name: Set trusted workflow policy revision + run: echo "VELNOR_WORKFLOW_POLICY_REVISION=4dec6b9ec28b0d51cb370fd8f5d5401c6186adf0" >> "$GITHUB_ENV" + - name: Select affected units + id: plan + env: + EVENT_NAME: ${{ github.event_name }} + CI_SCOPE_OVERRIDE: ${{ github.event.inputs.scope || '' }} + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.inputs.base_sha || github.event.before || 'refs/heads/main' }} + HEAD_SHA: ${{ github.sha }} + VELNOR_PROVIDERS: github-hosted + VELNOR_EVENT_TRUSTED: ${{ (!(github.event_name == 'pull_request' && (github.event.pull_request.head.repo.fork || github.event.pull_request.user.type == 'Bot'))) && 'true' || 'false' }} + VELNOR_EXPECTED_WORK_FILE: .velnor-ci-expected-work/expected-work.json + run: | + set -euo pipefail + if [[ -z "${CI_SCOPE_OVERRIDE:-}" ]]; then unset CI_SCOPE_OVERRIDE; fi + mkdir -p .velnor-ci-expected-work + velnor-workflow plan --config .github/ci/project.toml + + - name: Publish expected work + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: velnor-expected-work + path: .velnor-ci-expected-work/expected-work.json + if-no-files-found: error + retention-days: 7 + - name: Prepare Velnor workflow runtime + shell: bash + env: + EXPECTED_REVISION: 4dec6b9ec28b0d51cb370fd8f5d5401c6186adf0 + run: | + set -euo pipefail + stage="$RUNNER_TEMP/velnor-workflow-runtime" + rm -rf "$stage" + mkdir -p "$stage" + src="$(command -v velnor-workflow)" + install -m 0755 "$src" "$stage/velnor-workflow" + digest="$(sha256sum "$stage/velnor-workflow" | awk '{print $1}')" + policy_src="${VELNOR_WORKFLOW_PINNED_BINARY:-$src}" + install -m 0755 "$policy_src" "$stage/velnor-workflow-policy" + policy_revision="$("$stage/velnor-workflow-policy" --revision)" + policy_closure="$("$stage/velnor-workflow-policy" --closure)" + [[ "$policy_closure" == "${{ steps.runtime.outputs.closure }}" ]] || { echo "::error::policy runtime reports closure $policy_closure, expected ${{ steps.runtime.outputs.closure }}" >&2; exit 1; } + policy_digest="$(sha256sum "$stage/velnor-workflow-policy" | awk '{print $1}')" + jq -n --arg repository "$GITHUB_REPOSITORY" --arg revision "$EXPECTED_REVISION" --arg closure "${{ steps.runtime.outputs.closure }}" --arg head_branch "${{ github.ref_name }}" --arg platform "${{ runner.os }}-${{ runner.arch }}" --arg run_id "$GITHUB_RUN_ID" --arg job_id "${{ github.job }}" --arg binary_sha256 "$digest" --arg policy_revision "$policy_revision" --arg policy_closure "$policy_closure" --arg policy_binary_sha256 "$policy_digest" '{repository: $repository, revision: $revision, closure: $closure, head_branch: $head_branch, platform: $platform, run_id: $run_id, job_id: $job_id, binary_sha256: $binary_sha256, policy_revision: $policy_revision, policy_closure: $policy_closure, policy_binary_sha256: $policy_binary_sha256}' > "$stage/manifest.json" + - name: Publish Velnor workflow runtime + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: velnor-workflow-runtime-4dec6b9ec28b0d51cb370fd8f5d5401c6186adf0-${{ runner.os }}-${{ runner.arch }} + path: ${{ runner.temp }}/velnor-workflow-runtime + if-no-files-found: error + retention-days: 7 + policy: + name: Policy + runs-on: ubuntu-24.04 + timeout-minutes: 20 + # Trust invariant: this job runs the base branch's Stage-0 validator + # product against the audited tree under pull_request_target. It holds + # `contents: read` only, references no secrets, persists no credentials, + # and never compiles. When the audited tree differs from the declared + # pin's render, it additionally EXECUTES the PR run's prebuilt + # candidate generator — PR-built code, same-repository runs only, bound + # to the audited tree by manifest closure plus binary digest before + # execution — with no secret references, no persisted credentials, the + # read-only github.token confined to the Acquire/Ruleset API steps, + # and both candidate exec points tokenless. + permissions: + contents: read + steps: + - name: Checkout repository history + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: policy-checkout + fetch-depth: 0 + persist-credentials: false + - name: Check out audited head + working-directory: policy-checkout + env: + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name || github.repository }} + run: | + set -euo pipefail + if ! git cat-file -e "$HEAD_SHA^{commit}" 2>/dev/null; then + git fetch --no-tags "$GITHUB_SERVER_URL/$HEAD_REPOSITORY" "$HEAD_SHA" + fi + git checkout --quiet --detach "$HEAD_SHA" + - name: Set up Velnor workflow runtime + uses: tailrocks/velnor/.github/actions/setup-velnor-workflow@4dec6b9ec28b0d51cb370fd8f5d5401c6186adf0 + with: + rev: 4dec6b9ec28b0d51cb370fd8f5d5401c6186adf0 + checkout-path: ${{ github.workspace }}/policy-checkout + - name: Read declared generator pin + id: pin + working-directory: policy-checkout + run: | + set -euo pipefail + pin="$(sed -n -E 's/^[[:space:]]*revision[[:space:]]*=[[:space:]]*"([0-9a-f]{40})".*/\1/p' .github-gen/velnor-workflow.toml | head -n 1)" + test "$pin" != '' || pin="$(sed -n -E 's/^.*VELNOR_WORKFLOW_POLICY_REVISION:[[:space:]]*([0-9a-f]{40}).*/\1/p' .github/workflows/ci-policy.yml | head -n 1)" + test "$pin" != '' || { echo "::error::audited tree declares no generator pin" >&2; exit 1; } + echo "value=$pin" >> "$GITHUB_OUTPUT" + - name: Set up declared generator product + id: renderer + if: steps.pin.outputs.value != '4dec6b9ec28b0d51cb370fd8f5d5401c6186adf0' + uses: tailrocks/velnor/.github/actions/setup-velnor-workflow@4dec6b9ec28b0d51cb370fd8f5d5401c6186adf0 + with: + rev: ${{ steps.pin.outputs.value }} + checkout-path: ${{ github.workspace }}/policy-checkout + - name: Resolve declared generator product + if: steps.pin.outputs.value != '4dec6b9ec28b0d51cb370fd8f5d5401c6186adf0' + run: | + set -euo pipefail + binary="$HOME/.cache/velnor/workflow-runtime/${{ steps.renderer.outputs.closure }}/bin/velnor-workflow" + test -x "$binary" + echo "VELNOR_WORKFLOW_PINNED_BINARY=$binary" >> "$GITHUB_ENV" + - name: Resolve required status checks + env: + GH_TOKEN: ${{ github.token }} + DEFAULT_BRANCH: main + DECLARED_RULESET_CONTEXTS: DCO,Policy,ci-required + run: | + set -euo pipefail + stderr="$(mktemp)" + trap 'rm -f "$stderr"' EXIT + if contexts="$(gh api "repos/$GITHUB_REPOSITORY/rulesets?includes_parents=true" 2>"$stderr" \ + | jq -r '.[] | select(.target == "branch" and .enforcement == "active") | .id' \ + | while read -r id; do gh api "repos/$GITHUB_REPOSITORY/rulesets/$id"; done \ + | jq -r --arg branch "refs/heads/$DEFAULT_BRANCH" 'select(.conditions.ref_name.include | any(. == "~DEFAULT_BRANCH" or . == "~ALL" or . == $branch)) | .rules[] | select(.type == "required_status_checks") | .parameters.required_status_checks[].context' \ + | sort -u | paste -sd, -)"; then + : + elif grep -qE '(HTTP 403|Upgrade to GitHub Team)' "$stderr"; then + echo "::warning::rulesets API returned 403; falling back to declared contexts [$DECLARED_RULESET_CONTEXTS]" + contexts="$DECLARED_RULESET_CONTEXTS" + else + cat "$stderr" >&2 + exit 1 + fi + echo "RULESET_CONTEXTS=$contexts" >> "$GITHUB_ENV" + - name: Enforce workflow policy + env: + WORKFLOW_ROOT: ${{ github.workspace }}/policy-checkout + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + BASE_SHA: ${{ github.event.pull_request.base.sha || github.sha }} + VELNOR_WORKFLOW_POLICY_REVISION: 4dec6b9ec28b0d51cb370fd8f5d5401c6186adf0 + run: | + set -euo pipefail + velnor-workflow policy \ + --workflow-root "$WORKFLOW_ROOT" \ + --head-sha "$HEAD_SHA" \ + --base-sha "$BASE_SHA" \ + --candidate-manifest "${VELNOR_WORKFLOW_CANDIDATE_MANIFEST:-}" \ + --ruleset-contexts "$RULESET_CONTEXTS" + + - name: Set up actionlint + uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + with: + install_args: actionlint@1.7.12 + cache: false + - name: Lint caller workflows + working-directory: policy-checkout + env: + MISE_NO_CONFIG: "1" + run: mise exec actionlint@1.7.12 -- actionlint + github-hosted-docs: + name: "Documentation · Documentation · github-hosted — docs" + if: ${{ always() && needs.plan.result == 'success' && needs.policy.result == 'success' && contains(needs.plan.outputs.units, '"unit_id":"docs"') && (true) }} + needs: [plan, policy] + uses: ./.github/workflows/ci-unit-docs.yml + with: + unit: docs + provider: github-hosted + selected_units: ${{ needs.plan.outputs.units }} + selected_unit_ids: ${{ needs.plan.outputs.unit_ids }} + scope: ${{ needs.plan.outputs.scope }} + full_units: ${{ needs.plan.outputs.full_units }} + plan_digest: ${{ needs.plan.outputs.plan_digest }} + base_sha: ${{ needs.plan.outputs.base_sha }} + head_sha: ${{ needs.plan.outputs.head_sha }} + unit_platform: linux-x64 + unit_trust: untrusted-ok + unit_admission: github-hosted + ci-required: + name: ci-required + if: ${{ always() }} + needs: [plan, policy, github-hosted-docs] + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Download Velnor workflow runtime + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: velnor-workflow-runtime-4dec6b9ec28b0d51cb370fd8f5d5401c6186adf0-${{ runner.os }}-${{ runner.arch }} + path: .velnor-workflow-runtime + - name: Verify Velnor workflow runtime + shell: bash + env: + EXPECTED_REVISION: 4dec6b9ec28b0d51cb370fd8f5d5401c6186adf0 + run: | + set -euo pipefail + manifest=.velnor-workflow-runtime/manifest.json + jq -e --arg revision "$EXPECTED_REVISION" --arg repository "$GITHUB_REPOSITORY" --arg platform "${RUNNER_OS}-${RUNNER_ARCH}" --arg run_id "$GITHUB_RUN_ID" '.revision == $revision and .repository == $repository and .platform == $platform and .run_id == $run_id and (.run_id | test("^[0-9]+$")) and .job_id != "" and (.binary_sha256 | test("^[0-9a-f]{64}$")) and (.policy_binary_sha256 | test("^[0-9a-f]{64}$")) and (.closure | test("^[0-9a-f]{64}$")) and (.policy_closure | test("^[0-9a-f]{64}$"))' "$manifest" >/dev/null + expected="$(jq -er '.binary_sha256' "$manifest")" + actual="$(sha256sum .velnor-workflow-runtime/velnor-workflow | awk '{print $1}')" + [[ "$actual" == "$expected" ]] || { echo "::error::runtime digest mismatch" >&2; exit 1; } + expected="$(jq -er '.policy_binary_sha256' "$manifest")" + actual="$(sha256sum .velnor-workflow-runtime/velnor-workflow-policy | awk '{print $1}')" + [[ "$actual" == "$expected" ]] || { echo "::error::policy runtime digest mismatch" >&2; exit 1; } + - name: Add Velnor workflow runtime to PATH + shell: bash + env: + EXPECTED_REVISION: 4dec6b9ec28b0d51cb370fd8f5d5401c6186adf0 + run: | + set -euo pipefail + home="$RUNNER_TEMP/velnor-workflow-runtime-artifact" + install -Dm0755 .velnor-workflow-runtime/velnor-workflow "$home/bin/velnor-workflow" + install -Dm0755 .velnor-workflow-runtime/velnor-workflow-policy "$home/bin/velnor-workflow-policy" + expected_closure="$(jq -er '.policy_closure' .velnor-workflow-runtime/manifest.json)" + reported="$("$home/bin/velnor-workflow-policy" --closure)" + [[ "$reported" == "$expected_closure" ]] || { echo "::error::policy runtime reports closure $reported, expected $expected_closure" >&2; exit 1; } + echo "$home/bin" >> "$GITHUB_PATH" + echo "VELNOR_WORKFLOW_PINNED_BINARY=$home/bin/velnor-workflow-policy" >> "$GITHUB_ENV" + - name: Download expected work + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: velnor-expected-work + path: .velnor-ci-expected-work + - name: Download reported unit results + # A no-work plan runs no unit jobs, so zero result artifacts is the + # expected case there — and the aggregate fails a real-work plan with + # zero records anyway. Tolerate the empty download; never the verdict. + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: velnor-result-* + merge-multiple: true + path: .velnor-ci-results + - name: Collect reported unit results + shell: bash + run: | + set -euo pipefail + shopt -s nullglob + mkdir -p .velnor-ci-results + files=(.velnor-ci-results/result-*.json) + for file in "${files[@]}"; do + if jq -e 'any(.results[]?; has("reused_from"))' "$file" >/dev/null; then + echo "::error::$file carries reused_from without a validate_reuse decision; render emits no reused results" >&2 + exit 1 + fi + done + if (( ${#files[@]} == 0 )); then + printf '{"results":[]}\n' > .velnor-ci-results.json + else + jq -s '{results: ([.[].results // empty] | add // [])}' "${files[@]}" > .velnor-ci-results.json + fi + echo "collected $(jq '.results | length' .velnor-ci-results.json) reported result(s) from ${#files[@]} record file(s)" + - name: Score expected work against reported results + env: + BASE_SHA: ${{ needs.plan.outputs.base_sha }} + HEAD_SHA: ${{ needs.plan.outputs.head_sha }} + shell: bash + run: | + set -euo pipefail + velnor-workflow aggregate --expected .velnor-ci-expected-work/expected-work.json --results .velnor-ci-results.json + - name: Validate generated stack results + env: + NEEDS_JSON: ${{ toJSON(needs) }} + SELECTED_UNITS: ${{ needs.plan.outputs.units }} + PLAN_DIGEST: ${{ needs.plan.outputs.plan_digest }} + EXCLUDED: ${{ needs.plan.outputs.excluded }} + EXPECTED_CALLERS: "[{\"job_id\":\"github-hosted-docs\",\"provider\":\"github-hosted\",\"unit_id\":\"docs\"}]" + PROVIDER_ADMITTED_GITHUB_HOSTED: ${{ true }} + shell: bash + run: | + set -euo pipefail + if [[ -z "$PLAN_DIGEST" ]]; then + echo "plan did not freeze a plan digest: the expected set has no identity" >&2 + exit 1 + fi + echo "verdict binds plan digest $PLAN_DIGEST" + result_for_job() { + jq -r --arg job "$1" '.[$job].result // empty' <<<"$NEEDS_JSON" + } + plan_expects() { + [[ "$(jq -r --arg unit "$1" --arg provider "$2" '[.[] | select(.unit_id == $unit) | .providers[] | select(. == $provider)] | length' <<<"$SELECTED_UNITS")" -gt 0 ]] + } + if ! jq -e --argjson expected "$EXPECTED_CALLERS" '($expected | type == "array") and all($expected[]; type == "object" and (.unit_id | type) == "string" and (.provider | type) == "string" and (.job_id | type) == "string")' -n; then + echo "generated required-caller contract is malformed" >&2 + exit 1 + fi + if ! jq -e --argjson expected "$EXPECTED_CALLERS" 'type == "array" and (map(.unit_id) | unique | length) == length and all(.[]; . as $entry | ($entry | type) == "object" and ($entry.unit_id | type) == "string" and ($entry.unit_id | length) > 0 and ($entry.providers | type) == "array" and ($entry.providers | length) > 0 and (($entry.providers | map(type == "string" and length > 0) | all)) and (($entry.providers | unique | length) == ($entry.providers | length)) and all($entry.providers[]; . as $provider | any($expected[]; .unit_id == $entry.unit_id and .provider == $provider)))' <<<"$SELECTED_UNITS" >/dev/null; then + echo "plan selected-unit output contains an unknown, duplicate, empty, or unmapped obligation" >&2 + exit 1 + fi + if ! jq -e 'type == "object"' <<<"$NEEDS_JSON" >/dev/null; then + echo "workflow needs output is malformed" >&2 + exit 1 + fi + result="$(result_for_job plan)" + if [[ "$result" != success ]]; then + echo "required CI prerequisite plan did not pass: $result" >&2 + exit 1 + fi + result="$(result_for_job policy)" + if [[ "$result" != success ]]; then + echo "required CI prerequisite policy did not pass: $result" >&2 + exit 1 + fi + if plan_expects "docs" "github-hosted"; then + result="$(result_for_job github-hosted-docs)" + if [[ "$PROVIDER_ADMITTED_GITHUB_HOSTED" == true ]]; then + case "$result" in + success) ;; + skipped) echo "expected CI job github-hosted-docs was skipped: a skipped expected result cannot pass" >&2; exit 1 ;; + cancelled) echo "expected CI job github-hosted-docs was cancelled: a cancelled expected result cannot pass" >&2; exit 1 ;; + *) echo "expected CI job github-hosted-docs did not pass: $result" >&2; exit 1 ;; + esac + else + case "$result" in + skipped) ;; + *) echo "selected CI job github-hosted-docs ran outside its provider admission (PROVIDER_ADMITTED_GITHUB_HOSTED=$PROVIDER_ADMITTED_GITHUB_HOSTED): $result" >&2; exit 1 ;; + esac + fi + else + result="$(result_for_job github-hosted-docs)" + case "$result" in + skipped) ;; + success) echo "unexpected CI job github-hosted-docs succeeded outside the expected set: the plan did not declare it" >&2; exit 1 ;; + *) echo "unexpected CI job github-hosted-docs ran outside the expected set: $result" >&2; exit 1 ;; + esac + fi + required: + name: "Control / Required" + if: ${{ always() }} + needs: [ci-required] + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Mirror CI / Required + if: ${{ needs.ci-required.result != 'success' }} + run: exit 1 diff --git a/.github/workflows/ci-policy.yml b/.github/workflows/ci-policy.yml new file mode 100644 index 0000000..c4de0b5 --- /dev/null +++ b/.github/workflows/ci-policy.yml @@ -0,0 +1,126 @@ +# Generated by velnor-workflow. Regenerate; do not hand-edit. +name: Velnor workflow policy + +on: + pull_request_target: + types: [opened, synchronize, reopened] + workflow_dispatch: + +concurrency: + group: policy-${{ github.repository }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + policy: + name: Policy + runs-on: ubuntu-24.04 + timeout-minutes: 20 + # Trust invariant: this job runs the base branch's Stage-0 validator + # product against the audited tree under pull_request_target. It holds + # `contents: read` only, references no secrets, persists no credentials, + # and never compiles. When the audited tree differs from the declared + # pin's render, it additionally EXECUTES the PR run's prebuilt + # candidate generator — PR-built code, same-repository runs only, bound + # to the audited tree by manifest closure plus binary digest before + # execution — with no secret references, no persisted credentials, the + # read-only github.token confined to the Acquire/Ruleset API steps, + # and both candidate exec points tokenless. + permissions: + contents: read + steps: + - name: Checkout repository history + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: policy-checkout + fetch-depth: 0 + persist-credentials: false + - name: Check out audited head + working-directory: policy-checkout + env: + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name || github.repository }} + run: | + set -euo pipefail + if ! git cat-file -e "$HEAD_SHA^{commit}" 2>/dev/null; then + git fetch --no-tags "$GITHUB_SERVER_URL/$HEAD_REPOSITORY" "$HEAD_SHA" + fi + git checkout --quiet --detach "$HEAD_SHA" + - name: Set up Velnor workflow runtime + uses: tailrocks/velnor/.github/actions/setup-velnor-workflow@4dec6b9ec28b0d51cb370fd8f5d5401c6186adf0 + with: + rev: 4dec6b9ec28b0d51cb370fd8f5d5401c6186adf0 + checkout-path: ${{ github.workspace }}/policy-checkout + - name: Read declared generator pin + id: pin + working-directory: policy-checkout + run: | + set -euo pipefail + pin="$(sed -n -E 's/^[[:space:]]*revision[[:space:]]*=[[:space:]]*"([0-9a-f]{40})".*/\1/p' .github-gen/velnor-workflow.toml | head -n 1)" + test "$pin" != '' || pin="$(sed -n -E 's/^.*VELNOR_WORKFLOW_POLICY_REVISION:[[:space:]]*([0-9a-f]{40}).*/\1/p' .github/workflows/ci-policy.yml | head -n 1)" + test "$pin" != '' || { echo "::error::audited tree declares no generator pin" >&2; exit 1; } + echo "value=$pin" >> "$GITHUB_OUTPUT" + - name: Set up declared generator product + id: renderer + if: steps.pin.outputs.value != '4dec6b9ec28b0d51cb370fd8f5d5401c6186adf0' + uses: tailrocks/velnor/.github/actions/setup-velnor-workflow@4dec6b9ec28b0d51cb370fd8f5d5401c6186adf0 + with: + rev: ${{ steps.pin.outputs.value }} + checkout-path: ${{ github.workspace }}/policy-checkout + - name: Resolve declared generator product + if: steps.pin.outputs.value != '4dec6b9ec28b0d51cb370fd8f5d5401c6186adf0' + run: | + set -euo pipefail + binary="$HOME/.cache/velnor/workflow-runtime/${{ steps.renderer.outputs.closure }}/bin/velnor-workflow" + test -x "$binary" + echo "VELNOR_WORKFLOW_PINNED_BINARY=$binary" >> "$GITHUB_ENV" + - name: Resolve required status checks + env: + GH_TOKEN: ${{ github.token }} + DEFAULT_BRANCH: main + DECLARED_RULESET_CONTEXTS: DCO,Policy,ci-required + run: | + set -euo pipefail + stderr="$(mktemp)" + trap 'rm -f "$stderr"' EXIT + if contexts="$(gh api "repos/$GITHUB_REPOSITORY/rulesets?includes_parents=true" 2>"$stderr" \ + | jq -r '.[] | select(.target == "branch" and .enforcement == "active") | .id' \ + | while read -r id; do gh api "repos/$GITHUB_REPOSITORY/rulesets/$id"; done \ + | jq -r --arg branch "refs/heads/$DEFAULT_BRANCH" 'select(.conditions.ref_name.include | any(. == "~DEFAULT_BRANCH" or . == "~ALL" or . == $branch)) | .rules[] | select(.type == "required_status_checks") | .parameters.required_status_checks[].context' \ + | sort -u | paste -sd, -)"; then + : + elif grep -qE '(HTTP 403|Upgrade to GitHub Team)' "$stderr"; then + echo "::warning::rulesets API returned 403; falling back to declared contexts [$DECLARED_RULESET_CONTEXTS]" + contexts="$DECLARED_RULESET_CONTEXTS" + else + cat "$stderr" >&2 + exit 1 + fi + echo "RULESET_CONTEXTS=$contexts" >> "$GITHUB_ENV" + - name: Enforce workflow policy + env: + WORKFLOW_ROOT: ${{ github.workspace }}/policy-checkout + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + BASE_SHA: ${{ github.event.pull_request.base.sha || github.sha }} + VELNOR_WORKFLOW_POLICY_REVISION: 4dec6b9ec28b0d51cb370fd8f5d5401c6186adf0 + run: | + set -euo pipefail + velnor-workflow policy \ + --workflow-root "$WORKFLOW_ROOT" \ + --head-sha "$HEAD_SHA" \ + --base-sha "$BASE_SHA" \ + --candidate-manifest "${VELNOR_WORKFLOW_CANDIDATE_MANIFEST:-}" \ + --ruleset-contexts "$RULESET_CONTEXTS" + + - name: Set up actionlint + uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + with: + install_args: actionlint@1.7.12 + cache: false + - name: Lint caller workflows + working-directory: policy-checkout + env: + MISE_NO_CONFIG: "1" + run: mise exec actionlint@1.7.12 -- actionlint diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml new file mode 100644 index 0000000..f323c55 --- /dev/null +++ b/.github/workflows/ci-pr.yml @@ -0,0 +1,285 @@ +# Generated by velnor-workflow. Regenerate; do not hand-edit. +name: CI / PR +run-name: CI / PR · ${{ github.event_name }} · ${{ github.ref_name }} + +on: + pull_request: + workflow_dispatch: + inputs: + scope: + description: Verification scope + required: true + default: affected + type: choice + options: + - affected + - full + base_sha: + description: Git ref or SHA used as the affected-selection base + required: false + default: refs/heads/main + type: string + + +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + actions: read + contents: read + +jobs: + plan: + name: "Control / Planning" + runs-on: ubuntu-24.04 + outputs: + scope: ${{ steps.plan.outputs.scope }} + base_sha: ${{ steps.plan.outputs.base_sha }} + head_sha: ${{ steps.plan.outputs.head_sha }} + units: ${{ steps.plan.outputs.units }} + unit_ids: ${{ steps.plan.outputs.unit_ids }} + full_units: ${{ steps.plan.outputs.full_units }} + plan_digest: ${{ steps.plan.outputs.plan_digest }} + excluded: ${{ steps.plan.outputs.excluded }} + docs_matrix: ${{ steps.plan.outputs.docs_matrix }} + # Presence-only no-work marker: `planned_no_work` is `true` or absent, + # never `false`. Consumers MUST branch + # `needs.plan.outputs.planned_no_work == 'true'` for the no-work path; + # any other value (including absent) means the plan selected work. + planned_no_work: ${{ steps.plan.outputs.planned_no_work }} + no_work_reason: ${{ steps.plan.outputs.no_work_reason }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Set up Velnor workflow runtime + id: runtime + uses: tailrocks/velnor/.github/actions/setup-velnor-workflow@4dec6b9ec28b0d51cb370fd8f5d5401c6186adf0 + with: + rev: 4dec6b9ec28b0d51cb370fd8f5d5401c6186adf0 + - name: Set trusted workflow policy revision + run: echo "VELNOR_WORKFLOW_POLICY_REVISION=4dec6b9ec28b0d51cb370fd8f5d5401c6186adf0" >> "$GITHUB_ENV" + - name: Select affected units + id: plan + env: + EVENT_NAME: ${{ github.event_name }} + CI_SCOPE_OVERRIDE: ${{ github.event.inputs.scope || '' }} + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.inputs.base_sha || github.event.before || 'refs/heads/main' }} + HEAD_SHA: ${{ github.sha }} + VELNOR_PROVIDERS: github-hosted + VELNOR_EVENT_TRUSTED: ${{ (!(github.event_name == 'pull_request' && (github.event.pull_request.head.repo.fork || github.event.pull_request.user.type == 'Bot'))) && 'true' || 'false' }} + VELNOR_EXPECTED_WORK_FILE: .velnor-ci-expected-work/expected-work.json + run: | + set -euo pipefail + if [[ -z "${CI_SCOPE_OVERRIDE:-}" ]]; then unset CI_SCOPE_OVERRIDE; fi + mkdir -p .velnor-ci-expected-work + velnor-workflow plan --config .github/ci/project.toml + + - name: Publish expected work + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: velnor-expected-work + path: .velnor-ci-expected-work/expected-work.json + if-no-files-found: error + retention-days: 7 + - name: Prepare Velnor workflow runtime + shell: bash + env: + EXPECTED_REVISION: 4dec6b9ec28b0d51cb370fd8f5d5401c6186adf0 + run: | + set -euo pipefail + stage="$RUNNER_TEMP/velnor-workflow-runtime" + rm -rf "$stage" + mkdir -p "$stage" + src="$(command -v velnor-workflow)" + install -m 0755 "$src" "$stage/velnor-workflow" + digest="$(sha256sum "$stage/velnor-workflow" | awk '{print $1}')" + policy_src="${VELNOR_WORKFLOW_PINNED_BINARY:-$src}" + install -m 0755 "$policy_src" "$stage/velnor-workflow-policy" + policy_revision="$("$stage/velnor-workflow-policy" --revision)" + policy_closure="$("$stage/velnor-workflow-policy" --closure)" + [[ "$policy_closure" == "${{ steps.runtime.outputs.closure }}" ]] || { echo "::error::policy runtime reports closure $policy_closure, expected ${{ steps.runtime.outputs.closure }}" >&2; exit 1; } + policy_digest="$(sha256sum "$stage/velnor-workflow-policy" | awk '{print $1}')" + jq -n --arg repository "$GITHUB_REPOSITORY" --arg revision "$EXPECTED_REVISION" --arg closure "${{ steps.runtime.outputs.closure }}" --arg head_branch "${{ github.ref_name }}" --arg platform "${{ runner.os }}-${{ runner.arch }}" --arg run_id "$GITHUB_RUN_ID" --arg job_id "${{ github.job }}" --arg binary_sha256 "$digest" --arg policy_revision "$policy_revision" --arg policy_closure "$policy_closure" --arg policy_binary_sha256 "$policy_digest" '{repository: $repository, revision: $revision, closure: $closure, head_branch: $head_branch, platform: $platform, run_id: $run_id, job_id: $job_id, binary_sha256: $binary_sha256, policy_revision: $policy_revision, policy_closure: $policy_closure, policy_binary_sha256: $policy_binary_sha256}' > "$stage/manifest.json" + - name: Publish Velnor workflow runtime + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: velnor-workflow-runtime-4dec6b9ec28b0d51cb370fd8f5d5401c6186adf0-${{ runner.os }}-${{ runner.arch }} + path: ${{ runner.temp }}/velnor-workflow-runtime + if-no-files-found: error + retention-days: 7 + github-hosted-docs: + name: "Documentation · Documentation · github-hosted — docs" + if: ${{ !cancelled() && needs.plan.result == 'success' && contains(needs.plan.outputs.units, '"unit_id":"docs"') && (true) }} + needs: [plan] + uses: ./.github/workflows/ci-unit-docs.yml + with: + unit: docs + provider: github-hosted + selected_units: ${{ needs.plan.outputs.units }} + selected_unit_ids: ${{ needs.plan.outputs.unit_ids }} + scope: ${{ needs.plan.outputs.scope }} + full_units: ${{ needs.plan.outputs.full_units }} + plan_digest: ${{ needs.plan.outputs.plan_digest }} + base_sha: ${{ needs.plan.outputs.base_sha }} + head_sha: ${{ needs.plan.outputs.head_sha }} + unit_platform: linux-x64 + unit_trust: untrusted-ok + unit_admission: github-hosted + ci-required: + name: ci-required + if: ${{ !cancelled() }} + needs: [plan, github-hosted-docs] + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Download Velnor workflow runtime + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: velnor-workflow-runtime-4dec6b9ec28b0d51cb370fd8f5d5401c6186adf0-${{ runner.os }}-${{ runner.arch }} + path: .velnor-workflow-runtime + - name: Verify Velnor workflow runtime + shell: bash + env: + EXPECTED_REVISION: 4dec6b9ec28b0d51cb370fd8f5d5401c6186adf0 + run: | + set -euo pipefail + manifest=.velnor-workflow-runtime/manifest.json + jq -e --arg revision "$EXPECTED_REVISION" --arg repository "$GITHUB_REPOSITORY" --arg platform "${RUNNER_OS}-${RUNNER_ARCH}" --arg run_id "$GITHUB_RUN_ID" '.revision == $revision and .repository == $repository and .platform == $platform and .run_id == $run_id and (.run_id | test("^[0-9]+$")) and .job_id != "" and (.binary_sha256 | test("^[0-9a-f]{64}$")) and (.policy_binary_sha256 | test("^[0-9a-f]{64}$")) and (.closure | test("^[0-9a-f]{64}$")) and (.policy_closure | test("^[0-9a-f]{64}$"))' "$manifest" >/dev/null + expected="$(jq -er '.binary_sha256' "$manifest")" + actual="$(sha256sum .velnor-workflow-runtime/velnor-workflow | awk '{print $1}')" + [[ "$actual" == "$expected" ]] || { echo "::error::runtime digest mismatch" >&2; exit 1; } + expected="$(jq -er '.policy_binary_sha256' "$manifest")" + actual="$(sha256sum .velnor-workflow-runtime/velnor-workflow-policy | awk '{print $1}')" + [[ "$actual" == "$expected" ]] || { echo "::error::policy runtime digest mismatch" >&2; exit 1; } + - name: Add Velnor workflow runtime to PATH + shell: bash + env: + EXPECTED_REVISION: 4dec6b9ec28b0d51cb370fd8f5d5401c6186adf0 + run: | + set -euo pipefail + home="$RUNNER_TEMP/velnor-workflow-runtime-artifact" + install -Dm0755 .velnor-workflow-runtime/velnor-workflow "$home/bin/velnor-workflow" + install -Dm0755 .velnor-workflow-runtime/velnor-workflow-policy "$home/bin/velnor-workflow-policy" + expected_closure="$(jq -er '.policy_closure' .velnor-workflow-runtime/manifest.json)" + reported="$("$home/bin/velnor-workflow-policy" --closure)" + [[ "$reported" == "$expected_closure" ]] || { echo "::error::policy runtime reports closure $reported, expected $expected_closure" >&2; exit 1; } + echo "$home/bin" >> "$GITHUB_PATH" + echo "VELNOR_WORKFLOW_PINNED_BINARY=$home/bin/velnor-workflow-policy" >> "$GITHUB_ENV" + - name: Download expected work + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: velnor-expected-work + path: .velnor-ci-expected-work + - name: Download reported unit results + # A no-work plan runs no unit jobs, so zero result artifacts is the + # expected case there — and the aggregate fails a real-work plan with + # zero records anyway. Tolerate the empty download; never the verdict. + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: velnor-result-* + merge-multiple: true + path: .velnor-ci-results + - name: Collect reported unit results + shell: bash + run: | + set -euo pipefail + shopt -s nullglob + mkdir -p .velnor-ci-results + files=(.velnor-ci-results/result-*.json) + for file in "${files[@]}"; do + if jq -e 'any(.results[]?; has("reused_from"))' "$file" >/dev/null; then + echo "::error::$file carries reused_from without a validate_reuse decision; render emits no reused results" >&2 + exit 1 + fi + done + if (( ${#files[@]} == 0 )); then + printf '{"results":[]}\n' > .velnor-ci-results.json + else + jq -s '{results: ([.[].results // empty] | add // [])}' "${files[@]}" > .velnor-ci-results.json + fi + echo "collected $(jq '.results | length' .velnor-ci-results.json) reported result(s) from ${#files[@]} record file(s)" + - name: Score expected work against reported results + env: + BASE_SHA: ${{ needs.plan.outputs.base_sha }} + HEAD_SHA: ${{ needs.plan.outputs.head_sha }} + shell: bash + run: | + set -euo pipefail + velnor-workflow aggregate --expected .velnor-ci-expected-work/expected-work.json --results .velnor-ci-results.json + - name: Validate generated stack results + env: + NEEDS_JSON: ${{ toJSON(needs) }} + SELECTED_UNITS: ${{ needs.plan.outputs.units }} + PLAN_DIGEST: ${{ needs.plan.outputs.plan_digest }} + EXCLUDED: ${{ needs.plan.outputs.excluded }} + EXPECTED_CALLERS: "[{\"job_id\":\"github-hosted-docs\",\"provider\":\"github-hosted\",\"unit_id\":\"docs\"}]" + PROVIDER_ADMITTED_GITHUB_HOSTED: ${{ true }} + shell: bash + run: | + set -euo pipefail + if [[ -z "$PLAN_DIGEST" ]]; then + echo "plan did not freeze a plan digest: the expected set has no identity" >&2 + exit 1 + fi + echo "verdict binds plan digest $PLAN_DIGEST" + result_for_job() { + jq -r --arg job "$1" '.[$job].result // empty' <<<"$NEEDS_JSON" + } + plan_expects() { + [[ "$(jq -r --arg unit "$1" --arg provider "$2" '[.[] | select(.unit_id == $unit) | .providers[] | select(. == $provider)] | length' <<<"$SELECTED_UNITS")" -gt 0 ]] + } + if ! jq -e --argjson expected "$EXPECTED_CALLERS" '($expected | type == "array") and all($expected[]; type == "object" and (.unit_id | type) == "string" and (.provider | type) == "string" and (.job_id | type) == "string")' -n; then + echo "generated required-caller contract is malformed" >&2 + exit 1 + fi + if ! jq -e --argjson expected "$EXPECTED_CALLERS" 'type == "array" and (map(.unit_id) | unique | length) == length and all(.[]; . as $entry | ($entry | type) == "object" and ($entry.unit_id | type) == "string" and ($entry.unit_id | length) > 0 and ($entry.providers | type) == "array" and ($entry.providers | length) > 0 and (($entry.providers | map(type == "string" and length > 0) | all)) and (($entry.providers | unique | length) == ($entry.providers | length)) and all($entry.providers[]; . as $provider | any($expected[]; .unit_id == $entry.unit_id and .provider == $provider)))' <<<"$SELECTED_UNITS" >/dev/null; then + echo "plan selected-unit output contains an unknown, duplicate, empty, or unmapped obligation" >&2 + exit 1 + fi + if ! jq -e 'type == "object"' <<<"$NEEDS_JSON" >/dev/null; then + echo "workflow needs output is malformed" >&2 + exit 1 + fi + result="$(result_for_job plan)" + if [[ "$result" != success ]]; then + echo "required CI prerequisite plan did not pass: $result" >&2 + exit 1 + fi + if plan_expects "docs" "github-hosted"; then + result="$(result_for_job github-hosted-docs)" + if [[ "$PROVIDER_ADMITTED_GITHUB_HOSTED" == true ]]; then + case "$result" in + success) ;; + skipped) echo "expected CI job github-hosted-docs was skipped: a skipped expected result cannot pass" >&2; exit 1 ;; + cancelled) echo "expected CI job github-hosted-docs was cancelled: a cancelled expected result cannot pass" >&2; exit 1 ;; + *) echo "expected CI job github-hosted-docs did not pass: $result" >&2; exit 1 ;; + esac + else + case "$result" in + skipped) ;; + *) echo "selected CI job github-hosted-docs ran outside its provider admission (PROVIDER_ADMITTED_GITHUB_HOSTED=$PROVIDER_ADMITTED_GITHUB_HOSTED): $result" >&2; exit 1 ;; + esac + fi + else + result="$(result_for_job github-hosted-docs)" + case "$result" in + skipped) ;; + success) echo "unexpected CI job github-hosted-docs succeeded outside the expected set: the plan did not declare it" >&2; exit 1 ;; + *) echo "unexpected CI job github-hosted-docs ran outside the expected set: $result" >&2; exit 1 ;; + esac + fi + required: + name: "Control / Required" + if: ${{ !cancelled() }} + needs: [ci-required] + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Mirror CI / Required + if: ${{ needs.ci-required.result != 'success' }} + run: exit 1 diff --git a/.github/workflows/ci-unit-docs.yml b/.github/workflows/ci-unit-docs.yml index 89e53ff..75f6e96 100644 --- a/.github/workflows/ci-unit-docs.yml +++ b/.github/workflows/ci-unit-docs.yml @@ -9,6 +9,9 @@ on: selected_units: required: true type: string + selected_unit_ids: + required: true + type: string scope: required: true type: string @@ -21,7 +24,10 @@ on: head_sha: required: true type: string - lane: + plan_digest: + required: true + type: string + provider: required: true type: string mise_tools: @@ -108,6 +114,14 @@ on: required: false type: boolean default: false + unit_platform: + required: false + type: string + default: "" + unit_trust: + required: false + type: string + default: "" unit_dependencies: required: false type: string @@ -118,9 +132,9 @@ on: default: "" jobs: - verify-github: - name: GitHub - if: ${{ inputs.lane == 'github' && contains(format(',{0},', inputs.selected_units), format(',{0},', inputs.unit)) && (github.event_name == 'pull_request' || (github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'schedule')) || (github.event_name == 'workflow_dispatch' && (github.event.inputs.runner == 'github' || github.event.inputs.runner == 'both' || github.event.inputs.runner == ''))) }} + verify-github-hosted: + name: "GitHub · hosted" + if: ${{ inputs.provider == 'github-hosted' && contains(inputs.selected_units, format('"unit_id":"{0}"', inputs.unit)) && (true) }} runs-on: ubuntu-24.04 timeout-minutes: 45 steps: @@ -146,13 +160,13 @@ jobs: UNIT_ID: ${{ inputs.unit }} UNIT_DEPENDENCIES: ${{ inputs.unit_dependencies }} UNIT_ADMISSION: ${{ inputs.unit_admission }} - UNIT_LANE: ${{ inputs.lane }} + UNIT_PROVIDER: ${{ inputs.provider }} run: | { echo '## Unit dependencies' echo echo "- Unit: $UNIT_ID" - echo "- Lane: $UNIT_LANE" + echo "- Provider: $UNIT_PROVIDER" echo "- Admission: $UNIT_ADMISSION" if [[ -z "$UNIT_DEPENDENCIES" ]]; then echo '- Dependencies: none' @@ -163,12 +177,12 @@ jobs: - name: Download Velnor workflow runtime uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: velnor-workflow-runtime-4fa7a3a85f141a6bb95bc9bdf0eef9e3ddde165d-${{ runner.os }}-${{ runner.arch }} + name: velnor-workflow-runtime-4dec6b9ec28b0d51cb370fd8f5d5401c6186adf0-${{ runner.os }}-${{ runner.arch }} path: .velnor-workflow-runtime - name: Verify Velnor workflow runtime shell: bash env: - EXPECTED_REVISION: 4fa7a3a85f141a6bb95bc9bdf0eef9e3ddde165d + EXPECTED_REVISION: 4dec6b9ec28b0d51cb370fd8f5d5401c6186adf0 run: | set -euo pipefail manifest=.velnor-workflow-runtime/manifest.json @@ -182,7 +196,7 @@ jobs: - name: Add Velnor workflow runtime to PATH shell: bash env: - EXPECTED_REVISION: 4fa7a3a85f141a6bb95bc9bdf0eef9e3ddde165d + EXPECTED_REVISION: 4dec6b9ec28b0d51cb370fd8f5d5401c6186adf0 run: | set -euo pipefail home="$RUNNER_TEMP/velnor-workflow-runtime-artifact" @@ -211,18 +225,20 @@ jobs: SELECTION_BASE_SHA: ${{ inputs.base_sha }} SELECTION_HEAD_SHA: ${{ inputs.head_sha }} SELECTION_SCOPE: ${{ inputs.scope }} - SELECTION_UNITS: ${{ inputs.selected_units }} + SELECTION_UNITS: ${{ inputs.selected_unit_ids }} SELECTION_FULL_UNITS: ${{ inputs.full_units }} + SELECTION_PLAN_DIGEST: ${{ inputs.plan_digest }} run: | set -euo pipefail mkdir -p .velnor-ci-selection { - printf 'version=1\n' + printf 'version=2\n' printf 'base_sha=%s\n' "$SELECTION_BASE_SHA" printf 'head_sha=%s\n' "$SELECTION_HEAD_SHA" printf 'scope=%s\n' "$SELECTION_SCOPE" printf 'units=%s\n' "$SELECTION_UNITS" printf 'full_units=%s\n' "$SELECTION_FULL_UNITS" + printf 'plan_digest=%s\n' "$SELECTION_PLAN_DIGEST" } > .velnor-ci-selection/velnor-ci-selection - name: Mark selection transport end if: always() @@ -320,197 +336,34 @@ jobs: fi - name: Report phase timings and cache outcomes if: always() - uses: tailrocks/velnor/.github/actions/report-velnor-ci-outcomes@4fa7a3a85f141a6bb95bc9bdf0eef9e3ddde165d + uses: tailrocks/velnor/.github/actions/report-velnor-ci-outcomes@4dec6b9ec28b0d51cb370fd8f5d5401c6186adf0 with: job_label: ${{ inputs.unit }} ci_lane: github - - verify-velnor: - name: Velnor - if: ${{ inputs.lane == 'velnor' && contains(format(',{0},', inputs.selected_units), format(',{0},', inputs.unit)) && ((github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'schedule')) || (github.event_name == 'workflow_dispatch' && (github.event.inputs.runner == 'velnor' || github.event.inputs.runner == 'both' || github.event.inputs.runner == ''))) }} - runs-on: [self-hosted, velnor-target-mvp] - timeout-minutes: 45 - steps: - - name: Mark CI job start - if: always() - run: | - timing_dir="$RUNNER_TEMP/velnor-ci-timing-${GITHUB_RUN_ID:-unknown}-${GITHUB_RUN_ATTEMPT:-0}-${GITHUB_JOB:-unknown}" - umask 077 - mkdir -p "$timing_dir" - marker="$timing_dir/JOB_STARTED" - if [[ ! -e "$marker" ]]; then - if (set -C; printf '%s\n' "$(date +%s)" > "$marker") 2>/dev/null; then - chmod 0444 "$marker" 2>/dev/null || true - fi - fi - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - ref: ${{ inputs.head_sha }} - - name: Record unit dependencies + cache_declared_layers: + - name: Record unit result + if: ${{ always() }} env: - UNIT_ID: ${{ inputs.unit }} - UNIT_DEPENDENCIES: ${{ inputs.unit_dependencies }} - UNIT_ADMISSION: ${{ inputs.unit_admission }} - UNIT_LANE: ${{ inputs.lane }} - run: | - { - echo '## Unit dependencies' - echo - echo "- Unit: $UNIT_ID" - echo "- Lane: $UNIT_LANE" - echo "- Admission: $UNIT_ADMISSION" - if [[ -z "$UNIT_DEPENDENCIES" ]]; then - echo '- Dependencies: none' - else - echo "- Dependencies: $UNIT_DEPENDENCIES" - fi - } >> "$GITHUB_STEP_SUMMARY" - - name: Velnor runner identity + VELNOR_RESULT_UNIT: ${{ inputs.unit }} + VELNOR_RESULT_LANE: github-hosted + VELNOR_RESULT_OUTCOME: ${{ job.status }} shell: bash run: | set -euo pipefail - { - echo '## Velnor runner identity' - echo - echo "- Host: ${VELNOR_HOST:-unset}" - echo "- Instance: ${VELNOR_INSTANCE:-unset}" - echo "- Slot: ${VELNOR_SLOT:-unset}" - echo "- GitHub runner: ${RUNNER_NAME:-unset}" - echo "- OS/arch: ${RUNNER_OS:-unset}/${RUNNER_ARCH:-unset}" - echo "- Execution backend: ${VELNOR_EXECUTION_BACKEND:-unset}" - echo "- Velnor version: ${VELNOR_MANIFEST_VERSION:-${VELNOR_SOURCE_SHA:-unset}}" - } | tee -a "${GITHUB_STEP_SUMMARY:-/dev/null}" - - name: Mark runner setup end - if: always() - run: | - timing_dir="$RUNNER_TEMP/velnor-ci-timing-${GITHUB_RUN_ID:-unknown}-${GITHUB_RUN_ATTEMPT:-0}-${GITHUB_JOB:-unknown}" - umask 077 - mkdir -p "$timing_dir" - marker="$timing_dir/RUNNER_SETUP_ENDED" - if [[ ! -e "$marker" ]]; then - if (set -C; printf '%s\n' "$(date +%s)" > "$marker") 2>/dev/null; then - chmod 0444 "$marker" 2>/dev/null || true - fi - fi - - name: Materialize Velnor CI selection - shell: bash - env: - SELECTION_BASE_SHA: ${{ inputs.base_sha }} - SELECTION_HEAD_SHA: ${{ inputs.head_sha }} - SELECTION_SCOPE: ${{ inputs.scope }} - SELECTION_UNITS: ${{ inputs.selected_units }} - SELECTION_FULL_UNITS: ${{ inputs.full_units }} - run: | - set -euo pipefail - mkdir -p .velnor-ci-selection - { - printf 'version=1\n' - printf 'base_sha=%s\n' "$SELECTION_BASE_SHA" - printf 'head_sha=%s\n' "$SELECTION_HEAD_SHA" - printf 'scope=%s\n' "$SELECTION_SCOPE" - printf 'units=%s\n' "$SELECTION_UNITS" - printf 'full_units=%s\n' "$SELECTION_FULL_UNITS" - } > .velnor-ci-selection/velnor-ci-selection - - name: Mark selection transport end - if: always() - run: | - timing_dir="$RUNNER_TEMP/velnor-ci-timing-${GITHUB_RUN_ID:-unknown}-${GITHUB_RUN_ATTEMPT:-0}-${GITHUB_JOB:-unknown}" - umask 077 - mkdir -p "$timing_dir" - marker="$timing_dir/SELECTION_ENDED" - if [[ ! -e "$marker" ]]; then - if (set -C; printf '%s\n' "$(date +%s)" > "$marker") 2>/dev/null; then - chmod 0444 "$marker" 2>/dev/null || true - fi - fi - - name: Mark tool bootstrap end - if: always() - run: | - timing_dir="$RUNNER_TEMP/velnor-ci-timing-${GITHUB_RUN_ID:-unknown}-${GITHUB_RUN_ATTEMPT:-0}-${GITHUB_JOB:-unknown}" - umask 077 - mkdir -p "$timing_dir" - marker="$timing_dir/TOOL_BOOTSTRAP_ENDED" - if [[ ! -e "$marker" ]]; then - if (set -C; printf '%s\n' "$(date +%s)" > "$marker") 2>/dev/null; then - chmod 0444 "$marker" 2>/dev/null || true - fi - fi - - name: Mark cache prep end - if: always() - run: | - timing_dir="$RUNNER_TEMP/velnor-ci-timing-${GITHUB_RUN_ID:-unknown}-${GITHUB_RUN_ATTEMPT:-0}-${GITHUB_JOB:-unknown}" - umask 077 - mkdir -p "$timing_dir" - marker="$timing_dir/CACHE_PREP_ENDED" - if [[ ! -e "$marker" ]]; then - if (set -C; printf '%s\n' "$(date +%s)" > "$marker") 2>/dev/null; then - chmod 0444 "$marker" 2>/dev/null || true - fi - fi - - name: Mark cargo fetch end - if: always() - run: | - timing_dir="$RUNNER_TEMP/velnor-ci-timing-${GITHUB_RUN_ID:-unknown}-${GITHUB_RUN_ATTEMPT:-0}-${GITHUB_JOB:-unknown}" - umask 077 - mkdir -p "$timing_dir" - marker="$timing_dir/CARGO_FETCH_ENDED" - if [[ ! -e "$marker" ]]; then - if (set -C; printf '%s\n' "$(date +%s)" > "$marker") 2>/dev/null; then - chmod 0444 "$marker" 2>/dev/null || true - fi - fi - - name: Run unit checks - env: - CI_SCOPE: ${{ inputs.scope }} - CI_UNIT_ID: ${{ inputs.unit }} - EVENT_NAME: ${{ github.event_name }} - BASE_SHA: ${{ inputs.base_sha }} - HEAD_SHA: ${{ inputs.head_sha }} - VELNOR_SELECTION_FILE: .velnor-ci-selection/velnor-ci-selection - MISE_AUTO_INSTALL: "false" - MISE_EXEC_AUTO_INSTALL: "false" - MISE_NOT_FOUND_AUTO_INSTALL: "false" - run: | - set -o pipefail - timing_dir="$RUNNER_TEMP/velnor-ci-timing-${GITHUB_RUN_ID:-unknown}-${GITHUB_RUN_ATTEMPT:-0}-${GITHUB_JOB:-unknown}" - umask 077 - mkdir -p "$timing_dir" - marker="$timing_dir/CHECKS_STARTED" - if [[ ! -e "$marker" ]]; then - if (set -C; printf '%s\n' "$(date +%s)" > "$marker") 2>/dev/null; then - chmod 0444 "$marker" 2>/dev/null || true - fi - fi - rc=0 - velnor-workflow run --config .github/ci/project.toml --scope "$CI_SCOPE" --unit "$CI_UNIT_ID" 2>&1 | tee "$RUNNER_TEMP/velnor-unit-log.txt" || rc=$? - timing_dir="$RUNNER_TEMP/velnor-ci-timing-${GITHUB_RUN_ID:-unknown}-${GITHUB_RUN_ATTEMPT:-0}-${GITHUB_JOB:-unknown}" - umask 077 - mkdir -p "$timing_dir" - marker="$timing_dir/CHECKS_ENDED" - if [[ ! -e "$marker" ]]; then - if (set -C; printf '%s\n' "$(date +%s)" > "$marker") 2>/dev/null; then - chmod 0444 "$marker" 2>/dev/null || true - fi - fi - exit $rc - - name: Mark cleanup end - if: always() - run: | - timing_dir="$RUNNER_TEMP/velnor-ci-timing-${GITHUB_RUN_ID:-unknown}-${GITHUB_RUN_ATTEMPT:-0}-${GITHUB_JOB:-unknown}" - umask 077 - mkdir -p "$timing_dir" - marker="$timing_dir/CLEANUP_ENDED" - if [[ ! -e "$marker" ]]; then - if (set -C; printf '%s\n' "$(date +%s)" > "$marker") 2>/dev/null; then - chmod 0444 "$marker" 2>/dev/null || true - fi - fi - - name: Report phase timings and cache outcomes - if: always() - uses: tailrocks/velnor/.github/actions/report-velnor-ci-outcomes@4fa7a3a85f141a6bb95bc9bdf0eef9e3ddde165d + case "$VELNOR_RESULT_OUTCOME" in + success) outcome=success ;; + cancelled) outcome=cancelled ;; + *) outcome=failure ;; + esac + mkdir -p .velnor-ci-results + jq -n --arg unit "$VELNOR_RESULT_UNIT" --arg lane "$VELNOR_RESULT_LANE" --arg outcome "$outcome" '{results: [{unit: $unit, lane: $lane, outcome: $outcome}]}' > ".velnor-ci-results/result-$VELNOR_RESULT_UNIT-$VELNOR_RESULT_LANE.json" + - name: Upload unit result + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - job_label: ${{ inputs.unit }} - ci_lane: velnor + name: velnor-result-${{ inputs.unit }}-github-hosted + path: .velnor-ci-results/result-${{ inputs.unit }}-github-hosted.json + if-no-files-found: error + overwrite: true + retention-days: 7 diff --git a/.github/workflows/maintenance.yml b/.github/workflows/maintenance.yml new file mode 100644 index 0000000..3f7b85f --- /dev/null +++ b/.github/workflows/maintenance.yml @@ -0,0 +1,352 @@ +# Generated by velnor-workflow. Regenerate; do not hand-edit. +name: Maintenance +run-name: Maintenance · ${{ github.event_name }} + +on: + pull_request: + types: [closed] + schedule: + - cron: "31 3 * * *" + workflow_dispatch: + inputs: + pull_request_number: + description: Optional closed PR number whose merge cache should be removed + required: false + type: string + +permissions: + actions: write + contents: read + +concurrency: + group: maintenance-${{ github.repository }}-${{ github.event.pull_request.number || inputs.pull_request_number || github.run_id }} + cancel-in-progress: false + +jobs: + prune-pr-cache: + name: Prune closed-PR cache + if: ${{ github.event_name == 'pull_request' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && inputs.pull_request_number != '') }} + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Delete merge-ref cache namespace + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number || inputs.pull_request_number }} + run: | + set -euo pipefail + delete_cache_id() { + local id="$1" error attempt + for attempt in 1 2 3; do + error="$(gh api --method DELETE "repos/$GITHUB_REPOSITORY/actions/caches/$id" 2>&1 >/dev/null)" && return 0 + if grep -qi 'not found' <<<"$error"; then + return 0 + fi + if grep -Eq 'HTTP 40[13]' <<<"$error"; then + echo "::error::cache delete refused for id $id ($error); refusing to retry an authorization failure" >&2 + return 2 + fi + if (( attempt < 3 )); then + sleep $((attempt * 2)) + fi + done + echo "::error::failed to delete cache id $id after bounded retries" >&2 + return 1 + } + ref="refs/pull/$PR_NUMBER/merge" + encoded="$(printf '%s' "$ref" | jq -sRr @uri)" + listing="$(gh api --paginate "repos/$GITHUB_REPOSITORY/actions/caches?ref=$encoded" --jq '.actions_caches[].id')" || { + echo "::error::failed to list cache entries for $ref" >&2 + exit 1 + } + if [[ -z "$listing" ]]; then + echo "No merge-ref cache entries found for $ref" + exit 0 + fi + mapfile -t cache_ids <<<"$listing" + deleted=0 + failed=0 + for id in "${cache_ids[@]}"; do + [[ -z "$id" ]] && continue + if (( deleted + failed >= 500 )); then + echo "::error::maintenance delete bound reached (500 cache deletes); rerun maintenance to continue" >&2 + exit 1 + fi + if delete_cache_id "$id"; then + deleted=$((deleted + 1)) + else + status=$? + if (( status == 2 )); then + exit 1 + fi + failed=$((failed + 1)) + fi + done + if (( failed > 0 )); then + echo "::error::$failed closed-PR cache entries could not be deleted; rerun maintenance" >&2 + exit 1 + fi + cache-budget: + name: Cache retention + if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }} + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + actions: write + pull-requests: read + steps: + - name: Skip while CI producers are running + id: retention-gate + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + for workflow in ci-main.yml nightly.yml; do + if [[ "$(gh run list --repo "$GITHUB_REPOSITORY" --workflow "$workflow" --status in_progress --limit 1 --json databaseId --jq 'length')" != "0" ]]; then + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "$workflow is in_progress; skipping cache retention" >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + done + echo "skip=false" >> "$GITHUB_OUTPUT" + - name: Set up Velnor workflow runtime + id: runtime + uses: tailrocks/velnor/.github/actions/setup-velnor-workflow@4dec6b9ec28b0d51cb370fd8f5d5401c6186adf0 + with: + rev: 4dec6b9ec28b0d51cb370fd8f5d5401c6186adf0 + - name: Set trusted workflow policy revision + if: steps.retention-gate.outputs.skip != 'true' + run: echo "VELNOR_WORKFLOW_POLICY_REVISION=4dec6b9ec28b0d51cb370fd8f5d5401c6186adf0" >> "$GITHUB_ENV" + - name: Sweep closed-PR merge-ref caches + if: steps.retention-gate.outputs.skip != 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + delete_cache_id() { + local id="$1" error attempt + for attempt in 1 2 3; do + error="$(gh api --method DELETE "repos/$GITHUB_REPOSITORY/actions/caches/$id" 2>&1 >/dev/null)" && return 0 + if grep -qi 'not found' <<<"$error"; then + return 0 + fi + if grep -Eq 'HTTP 40[13]' <<<"$error"; then + echo "::error::cache delete refused for id $id ($error); refusing to retry an authorization failure" >&2 + return 2 + fi + if (( attempt < 3 )); then + sleep $((attempt * 2)) + fi + done + echo "::error::failed to delete cache id $id after bounded retries" >&2 + return 1 + } + failed=0 + processed=0 + all_refs="$(gh api --paginate "repos/$GITHUB_REPOSITORY/actions/caches?per_page=100" \ + --jq '.actions_caches[].ref')" || { + echo "::error::failed to list cache scopes" >&2 + exit 1 + } + mapfile -t refs < <(printf '%s\n' "$all_refs" | grep -E '^refs/pull/[0-9]+/merge$' | sort -u) + if ((${#refs[@]} == 0)); then + echo "No merge-ref cache scopes found" + exit 0 + fi + for ref in "${refs[@]}"; do + [[ -z "$ref" ]] && continue + pr="${ref#refs/pull/}" + pr="${pr%/merge}" + state="$(gh pr view "$pr" --json state --jq .state 2>/dev/null || echo unknown)" + if [[ "$state" != "CLOSED" ]]; then + continue + fi + encoded="$(printf '%s' "$ref" | jq -sRr @uri)" + listing="$(gh api --paginate "repos/$GITHUB_REPOSITORY/actions/caches?ref=$encoded" \ + --jq '.actions_caches[].id')" || { + echo "::error::failed to list cache entries for $ref" >&2 + exit 1 + } + if [[ -z "$listing" ]]; then + continue + fi + mapfile -t cache_ids <<<"$listing" + echo "Sweeping $ref ($pr): ${#cache_ids[@]} entries" + for id in "${cache_ids[@]}"; do + [[ -z "$id" ]] && continue + if (( processed >= 500 )); then + echo "::error::maintenance delete bound reached (500 cache deletes); rerun maintenance to continue" >&2 + exit 1 + fi + if delete_cache_id "$id"; then + : + else + status=$? + if (( status == 2 )); then + exit 1 + fi + failed=$((failed + 1)) + fi + processed=$((processed + 1)) + done + done + if (( failed > 0 )); then + echo "::error::$failed closed-PR merge-ref cache entries could not be deleted; rerun maintenance" >&2 + exit 1 + fi + - name: Collect Actions cache account + if: steps.retention-gate.outputs.skip != 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/cache-retention" + gh api --paginate "repos/$GITHUB_REPOSITORY/actions/caches?per_page=100" \ + --jq '.actions_caches[] | {id, key, size_in_bytes, created_at, last_accessed_at}' \ + > "$RUNNER_TEMP/cache-retention/entries.jsonl" + jq -s '.' "$RUNNER_TEMP/cache-retention/entries.jsonl" \ + > "$RUNNER_TEMP/cache-retention/entries.json" + velnor-workflow cache-plan --mode=budget --entries "$RUNNER_TEMP/cache-retention/entries.json" \ + > "$RUNNER_TEMP/cache-retention/budget.json" + # `gh api --paginate --jq` runs the filter once per page and + # concatenates the outputs: summing inside the filter prints one + # number per page, and the total silently understates the account. + # Slurp the page stream first, then take one total over every entry. + total="$(jq '.total_held_bytes' "$RUNNER_TEMP/cache-retention/budget.json")" + count="$(jq 'length' "$RUNNER_TEMP/cache-retention/entries.json")" + headroom="$(jq '.headroom_bytes' "$RUNNER_TEMP/cache-retention/budget.json")" + if (( headroom >= 0 && headroom < 536870912 )); then + echo "::warning::Actions cache headroom below 512 MiB ($headroom bytes remaining)" >&2 + fi + jq -n --argjson total "$total" --argjson count "$count" --argjson headroom "$headroom" \ + --slurpfile budget "$RUNNER_TEMP/cache-retention/budget.json" \ + --arg captured_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + '{captured_at: $captured_at, cache_count: $count, total_bytes: $total, headroom_bytes: $headroom, classes: $budget[0].classes}' \ + > "$RUNNER_TEMP/cache-retention/summary.json" + { + cat "$RUNNER_TEMP/cache-retention/summary.json" + echo "Per-class totals:" + jq -r '.classes[] | " \(.id): \(.entry_count) entries, \(.held_bytes) bytes (budget \(.budget_bytes))"' \ + "$RUNNER_TEMP/cache-retention/budget.json" + } >> "$GITHUB_STEP_SUMMARY" + - name: Plan retention evictions + if: steps.retention-gate.outputs.skip != 'true' + run: | + set -euo pipefail + # The plan is the generator's own retention policy, executed - never + # a shell copy of it: per-class budgets, bounded generations per + # variant class, and the protected classes (toolchain seeds, Cargo + # source bundles, the Docker seed baseline) reserved before rolling + # compiler snapshots are touched. An access timestamp is not a + # lease. The newest generation of a variant stays out of reach + # inside the producer window; a superseded generation of the same + # variant is eligible, because the newer save is the producer + # signal that the older entry is no longer being written. + velnor-workflow cache-plan \ + --now "$(date -u +%s)" \ + --entries "$RUNNER_TEMP/cache-retention/entries.json" \ + > "$RUNNER_TEMP/cache-retention/plan.json" + if jq -e 'length > 0' "$RUNNER_TEMP/cache-retention/plan.json" > /dev/null; then + { + echo "Retention plan (evict oldest first: bound, class budget, global budget):" + jq -r 'sort_by(.class, .reason) | group_by(.class, .reason)[] | " \(.[0].class) / \(.[0].reason): \(length) entries, \(map(.size_in_bytes) | add) bytes"' \ + "$RUNNER_TEMP/cache-retention/plan.json" + } >> "$GITHUB_STEP_SUMMARY" + else + echo "Retention plan: nothing to evict" >> "$GITHUB_STEP_SUMMARY" + fi + - name: Apply retention evictions + if: steps.retention-gate.outputs.skip != 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + delete_cache_id() { + local id="$1" error attempt + for attempt in 1 2 3; do + error="$(gh api --method DELETE "repos/$GITHUB_REPOSITORY/actions/caches/$id" 2>&1 >/dev/null)" && return 0 + if grep -qi 'not found' <<<"$error"; then + return 0 + fi + if grep -Eq 'HTTP 40[13]' <<<"$error"; then + echo "::error::cache delete refused for id $id ($error); refusing to retry an authorization failure" >&2 + return 2 + fi + if (( attempt < 3 )); then + sleep $((attempt * 2)) + fi + done + echo "::error::failed to delete cache id $id after bounded retries" >&2 + return 1 + } + evicted=0 + freed=0 + failed=0 + # The plan is applied verbatim, in its own order: generations beyond + # their class bound first, then classes over budget, then the global + # sweep - which never touches a protected class. The per-run delete + # bound stops the sweep instead of letting one run empty the + # account; the next run continues where this one stopped. + while IFS=$'\t' read -r class reason id size key; do + if (( evicted + failed >= 500 )); then + echo "::error::maintenance delete bound reached (500 cache deletes); rerun maintenance to continue" >&2 + exit 1 + fi + if delete_cache_id "$id"; then + evicted=$((evicted + 1)) + freed=$((freed + size)) + echo "evicted id=$id class=$class reason=$reason size=$size key=$key" + else + status=$? + if (( status == 2 )); then + exit 1 + fi + failed=$((failed + 1)) + echo "::warning::failed to evict cache id $id (class $class, key $key)" >&2 + fi + done < <(jq -r '.[] | [.class, .reason, .id, .size_in_bytes, .key] | @tsv' \ + "$RUNNER_TEMP/cache-retention/plan.json") + # Every eviction is recorded under its cache class and its reason, + # so a later cold run can be correlated with the eviction that + # caused it. + { + echo "Evictions by cache class:" + jq -r 'group_by(.class)[] | " \(.[0].class): \(length) evictions, \(map(.size_in_bytes) | add) bytes"' \ + "$RUNNER_TEMP/cache-retention/plan.json" + } >> "$GITHUB_STEP_SUMMARY" + jq -n --argjson evicted "$evicted" --argjson freed "$freed" --argjson failed "$failed" \ + '{evicted_caches: $evicted, failed_evictions: $failed, freed_bytes: $freed}' \ + >> "$GITHUB_STEP_SUMMARY" + # A failed eviction is a loud failure, never a swallowed warning: + # silent DELETE failures leave the account over budget while the + # run reports success. + if (( failed > 0 )); then + echo "::error::$failed retention evictions failed; rerun maintenance" >&2 + exit 1 + fi + - name: Publish retention evidence + if: steps.retention-gate.outputs.skip != 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: cache-retention-${{ github.run_id }} + path: ${{ runner.temp }}/cache-retention + if-no-files-found: error + retention-days: 14 + - name: Enforce cache budget + if: steps.retention-gate.outputs.skip != 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + # Re-query live state: the enforcement decision must reflect what the + # account actually holds after eviction, not the pre-eviction + # snapshot. The same page-streaming rule as collection applies: the + # sum is taken after slurping every page. + total="$(gh api --paginate "repos/$GITHUB_REPOSITORY/actions/caches?per_page=100" \ + --jq '.actions_caches[].size_in_bytes' | jq -s 'add // 0')" + budget="$(velnor-workflow cache-plan --mode=budget)" + if (( total > budget )); then + echo "::error::Actions cache account exceeds budget: $total > $budget bytes" >&2 + exit 1 + fi diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 0000000..41cf3fa --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,103 @@ +# Generated by velnor-workflow. Regenerate; do not hand-edit. +name: Nightly +run-name: Nightly · ${{ github.event_name }} · ${{ github.ref_name }} + +on: + schedule: + - cron: '17 3 * * *' + workflow_dispatch: + inputs: + scope: + description: Verification scope + required: true + default: full + type: choice + options: + - affected + - full + base_sha: + description: Git ref or SHA used as the affected-selection base + required: false + default: refs/heads/main + type: string + simulate_failure: + description: Force the red-to-signal test path + required: false + default: false + type: boolean + + +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: false + +permissions: + actions: read + contents: read + +jobs: + dispatch-ci-main: + name: "Control / Dispatch ci-main" + if: ${{ github.event_name != 'workflow_dispatch' || !inputs.simulate_failure }} + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + actions: write + contents: read + steps: + - name: Dispatch ci-main on default branch + env: + GH_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + DEFAULT_BRANCH: main + DISPATCH_SCOPE: ${{ github.event.inputs.scope || 'full' }} + DISPATCH_BASE_SHA: ${{ github.event.inputs.base_sha || format('refs/heads/{0}', github.event.repository.default_branch) }} + shell: bash + run: | + set -euo pipefail + gh workflow run ci-main.yml \ + -R "$GITHUB_REPOSITORY" \ + --ref "$DEFAULT_BRANCH" \ + -f scope="$DISPATCH_SCOPE" \ + -f base_sha="$DISPATCH_BASE_SHA" + nightly-red-to-signal: + name: "Control / Nightly red-to-signal" + if: ${{ github.event_name == 'workflow_dispatch' && inputs.simulate_failure }} + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Simulate nightly failure + shell: bash + run: | + set -euo pipefail + echo "nightly red-to-signal simulation requested" >&2 + exit 1 + nightly-alert: + name: "Control / Nightly red-to-signal" + if: ${{ always() && needs.nightly-red-to-signal.result == 'failure' }} + needs: [nightly-red-to-signal] + runs-on: ubuntu-24.04 + permissions: + contents: read + issues: write + steps: + - name: Open or update nightly failure signal + env: + GH_TOKEN: ${{ github.token }} + NIGHTLY_RESULT: ${{ needs.nightly-red-to-signal.result }} + shell: bash + run: | + set -euo pipefail + if [[ "$NIGHTLY_RESULT" == success ]]; then + exit 0 + fi + echo "::error::nightly-red-to-signal failed: $NIGHTLY_RESULT" + existing="$(gh api "repos/$GITHUB_REPOSITORY/issues?state=open" --jq '.[] | select(.title == "Nightly CI red") | .number' | sed -n '1p')" + body="$(printf '%s\n%s\n' \ + "nightly-red-to-signal result: $NIGHTLY_RESULT" \ + "Run: https://github.com/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID")" + if [[ -n "$existing" ]]; then + gh api --method PATCH "repos/$GITHUB_REPOSITORY/issues/$existing" -f body="$body" >/dev/null + else + gh api --method POST "repos/$GITHUB_REPOSITORY/issues" -f title='Nightly CI red' -f body="$body" >/dev/null + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0ad58f5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,302 @@ +# Generated by velnor-workflow. Regenerate; do not hand-edit. +name: Package feed +run-name: Package feed · apt · ${{ github.event_name }} + +on: + schedule: + - cron: '17 4 * * *' + workflow_dispatch: + inputs: + channel: + description: Package channel + required: false + default: stable + type: choice + options: + - stable + - preview + version: + description: Target version (empty discovers the channel head) + required: false + default: '' + type: string + commit: + description: Target source commit (empty resolves it) + required: false + default: '' + type: string + +concurrency: + group: package-feed-apt-${{ github.repository }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + verify: + name: Verify apt feed + runs-on: ubuntu-24.04 + timeout-minutes: 30 + outputs: + version: ${{ steps.feed.outputs.version }} + commit: ${{ steps.feed.outputs.commit }} + channel: ${{ steps.feed.outputs.channel }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Set up Velnor workflow runtime + id: runtime + uses: tailrocks/velnor/.github/actions/setup-velnor-workflow@4dec6b9ec28b0d51cb370fd8f5d5401c6186adf0 + with: + rev: 4dec6b9ec28b0d51cb370fd8f5d5401c6186adf0 + - name: Set trusted workflow policy revision + run: echo "VELNOR_WORKFLOW_POLICY_REVISION=4dec6b9ec28b0d51cb370fd8f5d5401c6186adf0" >> "$GITHUB_ENV" + - name: Enforce workflow policy + env: + EVENT_NAME: ${{ github.event_name }} + run: velnor-workflow policy --workflow-root "$GITHUB_WORKSPACE" + - name: Fetch and verify feed inputs + id: feed + env: + CHANNEL: ${{ github.event.inputs.channel || 'stable' }} + INPUT_VERSION: ${{ github.event.inputs.version || '' }} + INPUT_COMMIT: ${{ github.event.inputs.commit || '' }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + channel="$CHANNEL" + case "$channel" in stable|preview) ;; *) echo "::error::unknown channel $channel" >&2; exit 1 ;; esac + version="$INPUT_VERSION" + commit="$INPUT_COMMIT" + if [ "$channel" = stable ]; then + if [ -z "$version" ]; then + version="$(gh release list --repo 'tailrocks/holla' --exclude-drafts --exclude-pre-releases --limit 1 --json tagName --jq '.[0].tagName')" + fi + if [ -z "$commit" ]; then + commit="$(velnor-workflow release apt-resolve-commit --source-repo 'tailrocks/holla' --version "$version")" + fi + else + rm -rf discover + gh release download preview --repo 'tailrocks/holla' --pattern 'release-manifest.json' --dir discover + manifest_version="$(jq -er .version discover/release-manifest.json)" + if [ -z "$version" ]; then + version="$manifest_version" + elif [ "$version" != "$manifest_version" ]; then + echo "::error::requested $version disagrees with the rolling manifest $manifest_version" >&2 + exit 1 + fi + if [ -z "$commit" ]; then + commit="$(gh release view preview --repo 'tailrocks/holla' --json targetCommitish --jq .targetCommitish)" + fi + fi + rm -rf incoming + velnor-workflow release apt-fetch --suite "$channel" --source-repo 'tailrocks/holla' --package 'holla' --version "$version" --dir incoming + if [ "$channel" = stable ]; then + source_ref="refs/tags/$version" + else + source_ref="refs/heads/main" + fi + shopt -s nullglob + attest_subjects=(incoming/*.deb) + [ "${#attest_subjects[@]}" -gt 0 ] || { echo "::error::no fetched debs to attest" >&2; exit 1; } + for subject in "${attest_subjects[@]}"; do + gh attestation verify "$subject" --repo 'tailrocks/holla' --signer-workflow "tailrocks/holla/.github/workflows/ci-release-package-signer.yml" --source-ref "$source_ref" --source-digest "$commit" + done + live_fpr="$(gpg --show-keys --with-colons 'holla.gpg' | awk -F: '/^fpr:/{print $10; exit}')" + if [ "$channel" = stable ]; then + velnor-workflow release apt-verify --suite "$channel" --source-repo 'tailrocks/holla' --package 'holla' --binary 'holla' --identity-dir 'holla' --manifest-schema 'velnor.package-release.v1' --version "$version" --incoming incoming --commit "$commit" --signer "$live_fpr" --expect-signer '925B7B4B807283B2391DEF2DE5BC87724E0F3E0A' --verify-oci true + else + velnor-workflow release apt-verify --suite "$channel" --source-repo 'tailrocks/holla' --package 'holla' --binary 'holla' --identity-dir 'holla' --manifest-schema 'velnor.package-release.v1' --version "$version" --incoming incoming --commit "$commit" --signer "$live_fpr" --expect-signer '925B7B4B807283B2391DEF2DE5BC87724E0F3E0A' + fi + { + echo "version=$version" + echo "commit=$commit" + echo "channel=$channel" + } >> "$GITHUB_OUTPUT" + - name: Upload verified feed inputs + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: apt-incoming + path: incoming + include-hidden-files: true + if-no-files-found: error + retention-days: 2 + publish: + name: Publish apt feed + needs: [verify] + if: ${{ github.ref == 'refs/heads/main' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') }} + runs-on: ubuntu-24.04 + timeout-minutes: 30 + environment: package-feed + permissions: + contents: write + outputs: + version: ${{ needs.verify.outputs.version }} + commit: ${{ needs.verify.outputs.commit }} + channel: ${{ needs.verify.outputs.channel }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Set up Velnor workflow runtime + id: runtime + uses: tailrocks/velnor/.github/actions/setup-velnor-workflow@4dec6b9ec28b0d51cb370fd8f5d5401c6186adf0 + with: + rev: 4dec6b9ec28b0d51cb370fd8f5d5401c6186adf0 + - name: Set trusted workflow policy revision + run: echo "VELNOR_WORKFLOW_POLICY_REVISION=4dec6b9ec28b0d51cb370fd8f5d5401c6186adf0" >> "$GITHUB_ENV" + - name: Download verified feed inputs + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: apt-incoming + path: incoming + - name: Recover the prior pair and derive the previous pointer + id: prior + env: + CHANNEL: ${{ needs.verify.outputs.channel }} + VERSION: ${{ needs.verify.outputs.version }} + run: | + set -euo pipefail + rm -rf prev + mkdir -p prev + feed='https://holla-apt.tailrocks.com' + echo "bootstrap=false" >> "$GITHUB_OUTPUT" + case "$CHANNEL" in + stable) + prev_tag="$(curl --fail --show-error --silent --location "$feed/last-publish")" + prev_version="${prev_tag#v}" + case "$prev_version" in ''|*[!0-9.]*) echo "::error::live last-publish is not a version: $prev_tag" >&2; exit 1 ;; esac + for arch in amd64 arm64; do + curl --fail --show-error --silent --location --retry 3 \ + -o "prev/holla-$prev_version-$arch.deb" \ + "$feed/pool/main/h/holla/holla_${prev_version}_${arch}.deb" + done + candidate_sha="$(awk '{print $1}' incoming/release-record.json.sha256)" + curl --fail --show-error --silent --location -o published.json "$feed/publication-record.json" + velnor-workflow release apt-previous-pointer --suite stable --published published.json --prior "$prev_tag" --candidate "$VERSION" --candidate-sha "$candidate_sha" > previous-pointer.json + ;; + preview) + if curl --fail --show-error --silent --location --output /dev/null "$feed/dists/preview/InRelease"; then + curl --fail --show-error --silent --location -o live-packages "$feed/dists/preview/main/binary-amd64/Packages" + rollback="$(awk '$1=="Package:"{p=$2} p=="holla" && $1=="Version:"{print $2}' live-packages | sort -u | grep -Fxv "$VERSION")" + [ -n "$rollback" ] || { echo "::error::no retained rollback in the live preview index" >&2; exit 1; } + [ "$(printf '%s\n' "$rollback" | wc -l | tr -d ' ')" = 1 ] || { echo "::error::live preview index retains more than one rollback" >&2; exit 1; } + case "$rollback" in ''|*[!0-9A-Za-z.+:~-]*) echo "::error::live preview rollback is not a pool version: $rollback" >&2; exit 1 ;; esac + for arch in amd64 arm64; do + curl --fail --show-error --silent --location --retry 3 \ + -o "prev/holla_${rollback}_${arch}.deb" \ + "$feed/pool/preview/main/h/holla/holla_${rollback}_${arch}.deb" + done + velnor-workflow release apt-previous-pointer --suite preview > previous-pointer.json + else + velnor-workflow release apt-previous-pointer --suite preview --bootstrap true > previous-pointer.json + echo "bootstrap=true" >> "$GITHUB_OUTPUT" + fi + ;; + esac + - name: Publish the staged suite + env: + CHANNEL: ${{ needs.verify.outputs.channel }} + VERSION: ${{ needs.verify.outputs.version }} + COMMIT: ${{ needs.verify.outputs.commit }} + APT_GPG_PASSPHRASE: ${{ secrets.APT_GPG_PASSPHRASE }} + APT_GPG_PRIVATE_KEY: ${{ secrets.APT_GPG_PRIVATE_KEY }} + run: | + set -euo pipefail + args=(--suite "$CHANNEL" --source-repo 'tailrocks/holla' --package 'holla' --binary 'holla' --consumer-repo 'tailrocks/holla-apt' --manifest-schema 'velnor.package-release.v1' --identity-dir 'holla' --keyring 'holla.gpg' --origin 'Holla' --description 'apt repository for holla - adaptive dev environment CLI' --feed-url 'https://holla-apt.tailrocks.com' --signer '925B7B4B807283B2391DEF2DE5BC87724E0F3E0A' --passphrase-env APT_GPG_PASSPHRASE --key-env APT_GPG_PRIVATE_KEY --version "$VERSION" --incoming incoming --previous-pointer previous-pointer.json --staging public) + if [ "${{ steps.prior.outputs.bootstrap }}" = true ]; then + args+=(--bootstrap true) + else + args+=(--prev-dir prev) + fi + velnor-workflow release apt-publish "${args[@]}" + if [ "$CHANNEL" = stable ]; then + ref="refs/tags/$VERSION" + manifest="incoming/manifest.json" + else + ref="refs/heads/main" + manifest="incoming/release-manifest.json" + fi + velnor-workflow release apt-channel-update --suite "$CHANNEL" --source-repo 'tailrocks/holla' --source-ref "$ref" --commit "$COMMIT" --version "$VERSION" --package 'holla' --manifest "$manifest" --staging public + - name: Upload staged feed tree + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: apt-staging + path: public + if-no-files-found: error + retention-days: 2 + deploy: + name: Deploy apt feed + needs: [publish] + if: ${{ github.ref == 'refs/heads/main' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') }} + runs-on: ubuntu-24.04 + timeout-minutes: 20 + environment: github-pages + permissions: + contents: read + pages: write + id-token: write + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Set up Velnor workflow runtime + id: runtime + uses: tailrocks/velnor/.github/actions/setup-velnor-workflow@4dec6b9ec28b0d51cb370fd8f5d5401c6186adf0 + with: + rev: 4dec6b9ec28b0d51cb370fd8f5d5401c6186adf0 + - name: Set trusted workflow policy revision + run: echo "VELNOR_WORKFLOW_POLICY_REVISION=4dec6b9ec28b0d51cb370fd8f5d5401c6186adf0" >> "$GITHUB_ENV" + - name: Download staged feed tree + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: apt-staging + path: public + - name: Guard against a rollback deploy + env: + CHANNEL: ${{ needs.publish.outputs.channel }} + run: | + set -euo pipefail + if [ "$CHANNEL" = stable ]; then last="last-publish"; else last="last-publish-preview"; fi + live="unknown" + if curl --fail --show-error --silent --location -o live-last-publish 'https://holla-apt.tailrocks.com'/$last; then + live="$(cat live-last-publish)" + fi + velnor-workflow release apt-deploy-guard --suite "$CHANNEL" --staged public --live-version "$live" + - name: Configure Pages + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: public + - name: Deploy Pages + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 + feed-result: + name: Feed result + needs: [verify, publish, deploy] + if: ${{ always() }} + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Fail closed unless the feed verified and published + env: + REF: ${{ github.ref }} + VERIFY: ${{ needs.verify.result }} + PUBLISH: ${{ needs.publish.result }} + DEPLOY: ${{ needs.deploy.result }} + run: | + set -euo pipefail + [ "$VERIFY" = success ] || { echo "::error::feed verification did not succeed: $VERIFY" >&2; exit 1; } + if [ "$REF" = "refs/heads/main" ]; then + [ "$PUBLISH" = success ] || { echo "::error::feed publication did not succeed: $PUBLISH" >&2; exit 1; } + [ "$DEPLOY" = success ] || { echo "::error::feed deployment did not succeed: $DEPLOY" >&2; exit 1; } + else + [ "$PUBLISH" = skipped ] || { echo "::error::unexpected publication state off the default branch: $PUBLISH" >&2; exit 1; } + [ "$DEPLOY" = skipped ] || { echo "::error::unexpected deployment state off the default branch: $DEPLOY" >&2; exit 1; } + fi From cb729c844810e226eb0b0183e7bb7405c348d5b2 Mon Sep 17 00:00:00 2001 From: Alexey Zhokhov Date: Tue, 22 Sep 2026 00:32:39 +0700 Subject: [PATCH 2/2] fix(ci): run the docs unit's check task; port stale publish.yml test assertions The s2 runtime refuses command-less CI units, and the s1 tree ran none. Wire ci_tasks=[check] with lock-pinned tools, and port test-package-update.sh's publication assertions from the deleted hand publish.yml to the generated release.yml (verify-before-mutate ordering, attestation + apt-verify/apt-publish presence, handoff artifacts, mutable-authority negatives). check is green again (was red-on-main). Signed-off-by: Alexey Zhokhov --- .github-gen/velnor-workflow.toml | 6 +++++ .github/ci/.github-actions-generator-state | 10 ++++---- .github/ci/project.toml | 4 ++-- .github/workflows/ci-main.yml | 1 + .github/workflows/ci-pr.yml | 1 + .github/workflows/ci-unit-docs.yml | 6 +++++ scripts/test-package-update.sh | 27 +++++++++++++++------- 7 files changed, 40 insertions(+), 15 deletions(-) diff --git a/.github-gen/velnor-workflow.toml b/.github-gen/velnor-workflow.toml index f8eb632..76a26b2 100644 --- a/.github-gen/velnor-workflow.toml +++ b/.github-gen/velnor-workflow.toml @@ -58,3 +58,9 @@ id = "docs" label = "Documentation" kind = "docs" root = "." +# s2 runtime requires every CI unit to declare commands; the s1 tree ran +# none. `check` is the repo's human aggregate (actionlint + shellcheck + +# package-update-test, whose publication assertions now target the generated +# release.yml). Tools below must stay lock-pinned (see velnor-apt). +ci_tasks = ["check"] +mise_tools = ["actionlint", "jq", "ripgrep", "shellcheck"] diff --git a/.github/ci/.github-actions-generator-state b/.github/ci/.github-actions-generator-state index 77b4dd9..20d4fb5 100644 --- a/.github/ci/.github-actions-generator-state +++ b/.github/ci/.github-actions-generator-state @@ -1,17 +1,17 @@ # Generated ownership state; do not edit. schema = 2 [inputs] -config 492fb19e326d7a3a +config 7220ca5849f32df4 scan 91be9c9948367bd2 generator 69 [outputs] .github/AGENTS.md 02ebb7198c435e9b .github/actionlint.yaml c642e98fce5b1906 -.github/ci/project.toml 0c6676915893c403 -.github/workflows/ci-main.yml bfc71c80ba609e3a +.github/ci/project.toml 0a8b113b173a96e1 +.github/workflows/ci-main.yml 1405cceb65d68ebf .github/workflows/ci-policy.yml 6ed3b0156220a772 -.github/workflows/ci-pr.yml 878bc076414eeec5 -.github/workflows/ci-unit-docs.yml fa2dd72e726a8525 +.github/workflows/ci-pr.yml 3a5a80f7f905ef02 +.github/workflows/ci-unit-docs.yml 295416c0e0720839 .github/workflows/maintenance.yml 40515e95a46d5259 .github/workflows/nightly.yml 0659fbb3d6af2359 .github/workflows/release.yml 7eb1a5edd73ae668 diff --git a/.github/ci/project.toml b/.github/ci/project.toml index 280e1eb..672dd5e 100644 --- a/.github/ci/project.toml +++ b/.github/ci/project.toml @@ -34,8 +34,8 @@ label = "Documentation" kind = "docs" root = "." watch = [] -pr_commands = [] -full_commands = [] +pr_commands = ["mise run check"] +full_commands = ["mise run check"] platform = "linux-x64" trust = "untrusted-ok" [unit.capabilities] diff --git a/.github/workflows/ci-main.yml b/.github/workflows/ci-main.yml index e8a4730..b689106 100644 --- a/.github/workflows/ci-main.yml +++ b/.github/workflows/ci-main.yml @@ -237,6 +237,7 @@ jobs: plan_digest: ${{ needs.plan.outputs.plan_digest }} base_sha: ${{ needs.plan.outputs.base_sha }} head_sha: ${{ needs.plan.outputs.head_sha }} + mise_tools: "actionlint jq ripgrep shellcheck" unit_platform: linux-x64 unit_trust: untrusted-ok unit_admission: github-hosted diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml index f323c55..ce44549 100644 --- a/.github/workflows/ci-pr.yml +++ b/.github/workflows/ci-pr.yml @@ -126,6 +126,7 @@ jobs: plan_digest: ${{ needs.plan.outputs.plan_digest }} base_sha: ${{ needs.plan.outputs.base_sha }} head_sha: ${{ needs.plan.outputs.head_sha }} + mise_tools: "actionlint jq ripgrep shellcheck" unit_platform: linux-x64 unit_trust: untrusted-ok unit_admission: github-hosted diff --git a/.github/workflows/ci-unit-docs.yml b/.github/workflows/ci-unit-docs.yml index 75f6e96..b63fac9 100644 --- a/.github/workflows/ci-unit-docs.yml +++ b/.github/workflows/ci-unit-docs.yml @@ -252,6 +252,12 @@ jobs: chmod 0444 "$marker" 2>/dev/null || true fi fi + - name: Set up Mise tools + uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + with: + install_args: ${{ inputs.mise_tools }} + cache: true + cache_save: ${{ (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') }} - name: Mark tool bootstrap end if: always() run: | diff --git a/scripts/test-package-update.sh b/scripts/test-package-update.sh index 9cc700a..17bc023 100755 --- a/scripts/test-package-update.sh +++ b/scripts/test-package-update.sh @@ -37,14 +37,25 @@ if (cd "$tmp/repo" && VELNOR_VERIFIED_PACKAGE_DIR="$verified" ./scripts/package- exit 1 fi -publish="$root/.github/workflows/publish.yml" -verify_line=$(rg -n 'name: Download and verify merged package state' "$publish" | cut -d: -f1) -gpg_line=$(rg -n 'name: Import GPG signing key' "$publish" | cut -d: -f1) -test "$verify_line" -lt "$gpg_line" -rg -q 'gh attestation verify "incoming/\$name"' "$publish" -rg -q 'sha256sum --check --strict incoming/consumer-SHA256SUMS' "$publish" -rg -q 'paths: \[package-state.json\]' "$publish" -if rg -q 'repository_dispatch|event\.client_payload|release view --json tagName|Download \.debs from this repo' "$publish"; then +# The hand-written publish.yml is gone: publication is the generated +# release.yml (Package feed) now. Same verify-before-mutate contract, +# asserted against the generated shape. +release="$root/.github/workflows/release.yml" +# The publish job needs the verify job, and verify's steps precede the +# publish steps in the file. +rg -q 'needs: \[verify\]' "$release" +verify_line=$(rg -n 'name: Fetch and verify feed inputs' "$release" | cut -d: -f1) +publish_line=$(rg -n 'name: Publish the staged suite' "$release" | cut -d: -f1) +test "$verify_line" -lt "$publish_line" +# Every fetched deb is attested against the source repo, the suite gates on +# apt-verify, publication goes through apt-publish only, and the +# verify-to-publish-to-deploy handoff artifacts exist. +rg -q 'gh attestation verify "\$subject" --repo' "$release" +rg -q 'velnor-workflow release apt-verify' "$release" +rg -q 'velnor-workflow release apt-publish' "$release" +rg -q 'name: apt-incoming' "$release" +rg -q 'name: apt-staging' "$release" +if rg -q 'repository_dispatch|event\.client_payload|release view --json tagName|Download \.debs from this repo' "$release"; then echo "publication regained mutable or cross-upload authority" >&2 exit 1 fi