From 8ad34689a4a23e3d354b37aafcf5099c0b6c448c Mon Sep 17 00:00:00 2001 From: Jari Mustonen Date: Sun, 6 Sep 2026 10:10:45 +0300 Subject: [PATCH 1/4] fix: authorize release gates on hosted runners Fixes-Issue: @taskfleet-release-gate-ci-portability --- .github/build-setup.yml | 5 +- .github/workflows/ci.yml | 5 ++ .github/workflows/publish-crates.yml | 25 +++++-- .github/workflows/release.yml | 2 +- crates/taskfleet/tests/package_graph.rs | 24 +++++++ issues/taskfleet-release-0-6-0/validation.md | 13 +++- .../credential-ruleset-read.json | 20 ++++++ .../item.md | 8 +++ scripts/test-release-authorization.sh | 25 +++++++ scripts/test-release-github-policy.sh | 68 +++++++++++++++++++ scripts/validate-distribution-topology.sh | 19 +++++- scripts/verify-release-github-policy.sh | 46 +++++++++++-- 12 files changed, 244 insertions(+), 16 deletions(-) create mode 100644 issues/taskfleet-release-gate-ci-portability/credential-ruleset-read.json create mode 100755 scripts/test-release-github-policy.sh diff --git a/.github/build-setup.yml b/.github/build-setup.yml index 798e96fe..50708321 100644 --- a/.github/build-setup.yml +++ b/.github/build-setup.yml @@ -1,7 +1,10 @@ - name: Require wrapper-authorized exact-main release tag shell: bash + # Ruleset bypass actors are visible only with repository Administration read. + # This workflow is generated tag-only, so the release credential is never + # evaluated for pull requests or other untrusted refs. env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} run: ./scripts/verify-release-tag-authorization.sh - name: Remove build-account paths from Rust artifacts shell: bash diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5bc23658..dee45790 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -163,6 +163,11 @@ jobs: test "$($dist_bin --version)" = 'cargo-dist 0.28.2' echo "DIST_BIN=$dist_bin" >> "$GITHUB_ENV" - run: '"$DIST_BIN" generate --check' + - name: Exercise release policy parser on hosted jq 1.6 + shell: bash + run: | + test "$(jq --version)" = jq-1.6 + ./scripts/test-release-github-policy.sh - run: ./scripts/test-release-authorization.sh - name: Validate exact generated release plan and coherent activation state shell: bash diff --git a/.github/workflows/publish-crates.yml b/.github/workflows/publish-crates.yml index f9558fb2..12bf6dfc 100644 --- a/.github/workflows/publish-crates.yml +++ b/.github/workflows/publish-crates.yml @@ -20,6 +20,22 @@ env: CARGO_TERM_COLOR: always jobs: + release-authorization: + name: protected release authorization + if: ${{ github.event_name == 'push' }} + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@1.98.0 + - name: Require wrapper-authorized exact-main release tag + shell: bash + # This job exists only for tag pushes. Manual package inspection and + # untrusted PR workflows have no path to this administration credential. + env: + GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + run: ./scripts/verify-release-tag-authorization.sh + release-version: name: release topology and version runs-on: ubuntu-latest @@ -27,18 +43,13 @@ jobs: steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@1.98.0 - - name: Verify exact five-leg topology, pins, repository, tag, and wrapper authorization + - name: Verify exact five-leg topology, pins, repository, and version shell: bash - env: - GH_TOKEN: ${{ github.token }} run: | set -euo pipefail version="$(awk -F'"' '/^\[workspace\.package\]/{p=1;next} /^\[/{p=0} p&&/^version[[:space:]]*=/{print $2;exit}' Cargo.toml)" test "$GITHUB_REF_TYPE" != tag || test "$GITHUB_REF_NAME" = "v$version" repo="$(./scripts/validate-release-topology.sh)" - if test "$GITHUB_REF_TYPE" = tag; then - ./scripts/verify-release-tag-authorization.sh - fi cargo metadata --locked --no-deps --format-version 1 | jq -e --arg version "$version" --arg repo "https://github.com/$repo" ' ([.packages[].name] | sort) == ["orchestratectl","taskfleet","taskfleet-core"] and all(.packages[]; .version == $version and .repository == $repo) and @@ -141,7 +152,7 @@ jobs: publish-core: name: crates.io / taskfleet-core - needs: [release-version, fmt, version-snapshots, clippy, test, msrv, doc, deny] + needs: [release-authorization, release-version, fmt, version-snapshots, clippy, test, msrv, doc, deny] if: ${{ github.event_name == 'push' }} runs-on: ubuntu-latest timeout-minutes: 30 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ee98784d..7982bb3e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -128,7 +128,7 @@ jobs: run: "./scripts/verify-release-tag-authorization.sh" shell: "bash" env: - "GH_TOKEN": "${{ github.token }}" + "GH_TOKEN": "${{ secrets.HOMEBREW_TAP_TOKEN }}" - name: "Remove build-account paths from Rust artifacts" run: "printf 'RUSTFLAGS=%s\\n' \"${RUSTFLAGS:+$RUSTFLAGS }--remap-path-prefix=$GITHUB_WORKSPACE=/workspace --remap-path-prefix=$HOME=/build\" >> \"$GITHUB_ENV\"" shell: "bash" diff --git a/crates/taskfleet/tests/package_graph.rs b/crates/taskfleet/tests/package_graph.rs index ffa05821..4ad6500d 100644 --- a/crates/taskfleet/tests/package_graph.rs +++ b/crates/taskfleet/tests/package_graph.rs @@ -108,6 +108,30 @@ fn active_distribution_is_taskfleet_only_and_structurally_authorized() { .count(), 1 ); + assert_eq!( + workflow + .matches("\"GH_TOKEN\": \"${{ secrets.HOMEBREW_TAP_TOKEN }}\"") + .count(), + 1 + ); + assert!(!workflow.contains("\"GH_TOKEN\": \"${{ github.token }}\"")); + + let publish = std::fs::read_to_string(root.join(".github/workflows/publish-crates.yml")) + .expect("crates release workflow"); + assert!(!publish.contains("pull_request:")); + assert_eq!( + publish + .matches("GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}") + .count(), + 1 + ); + assert!(publish.contains( + "release-authorization:\n name: protected release authorization\n if: ${{ github.event_name == 'push' }}" + )); + assert!(publish.contains( + "needs: [release-authorization, release-version, fmt, version-snapshots, clippy, test, msrv, doc, deny]" + )); + assert!(!publish.contains("GH_TOKEN: ${{ github.token }}")); let build_setup = std::fs::read_to_string(root.join(".github/build-setup.yml")) .expect("cargo-dist build setup"); diff --git a/issues/taskfleet-release-0-6-0/validation.md b/issues/taskfleet-release-0-6-0/validation.md index 16cba736..34766daf 100644 --- a/issues/taskfleet-release-0-6-0/validation.md +++ b/issues/taskfleet-release-0-6-0/validation.md @@ -42,6 +42,17 @@ No cargo-dist release workflow ran for the PR; the exact-head workflow list cont Two adversarial rounds and a context follow-up were assessed in `evidence/assessment.{json,md}`. Every confirmed in-scope release-safety or evidence gap was fixed. The retained constraints are explicit upstream/trust-boundary facts: cargo-dist 0.28.2 emits workflow-wide `contents: write`, its host tolerates skipped local jobs, and a repository administrator remains the policy authority. No review residual met the bar for a new issue. +## v0.6.0 publication failure and fix-forward + +The conductor subsequently authorized and pushed immutable tag `v0.6.0` at `57f6dfb83401694399b363de5d3aa88e4541a22c` after exact-main CI run `34016341659`. Both independent publication workflows failed closed in the authorization gate before publication: + +- crates workflow `34016740702`, gate job `101441707888`; +- cargo-dist workflow `34016740704`, build jobs `101441745244`, `101441745248`, and `101441745351`. + +No crate, release asset, GitHub Release, or Homebrew formula was published. The tag and its authorization ref remain immutable and must not be reused. + +The failures had two concrete causes. GitHub-hosted jq 1.6 rejected the filter variable `$include` because `include` is reserved. On the self-hosted macOS jq 1.8.2 runner the workflow `GITHUB_TOKEN` could read the public ruleset shape, but GitHub redacted `bypass_actors`; that field requires repository Administration read, which is not a grantable `GITHUB_TOKEN` permission. The focused fix-forward issue `@taskfleet-release-gate-ci-portability` changes the jq variable, provides the already SOPS-managed release credential only to push/tag authorization steps, adds non-secret diagnostics and fixtures, and targets a fresh v0.6.1 transaction. It does not retroactively make v0.6.0 published. + ## Remaining conductor gate -After Taskfleet merges this branch, the conductor must wait for green push CI on the exact merged `main` SHA before invoking the Phase C wrapper. Phase C remains unchecked and no release coordinate is authorized by this document. +After the focused portability fix merges, the conductor must wait for green push CI on that exact merged `main` SHA and create a new sealed patch plan before invoking the wrapper for v0.6.1. Phase C remains unchecked for that new coordinate; this document does not authorize a release action or reuse of v0.6.0. diff --git a/issues/taskfleet-release-gate-ci-portability/credential-ruleset-read.json b/issues/taskfleet-release-gate-ci-portability/credential-ruleset-read.json new file mode 100644 index 00000000..7c20a150 --- /dev/null +++ b/issues/taskfleet-release-gate-ci-portability/credential-ruleset-read.json @@ -0,0 +1,20 @@ +{ + "schema_version": 1, + "checked_at": "2026-09-06T06:48:14Z", + "credential": "Homebase SOPS homebrew token (value not persisted)", + "repository": "jarimustonen/taskfleet", + "endpoints": [ + "GET /repos/jarimustonen/taskfleet/rulesets/22234415", + "GET /repos/jarimustonen/taskfleet/rulesets/22234417" + ], + "http_statuses": [ + 200, + 200 + ], + "ruleset_ids": [ + 22234415, + 22234417 + ], + "privileged_bypass_actors_visible": true, + "secret_value_logged": false +} diff --git a/issues/taskfleet-release-gate-ci-portability/item.md b/issues/taskfleet-release-gate-ci-portability/item.md index de6db047..7979a1d8 100644 --- a/issues/taskfleet-release-gate-ci-portability/item.md +++ b/issues/taskfleet-release-gate-ci-portability/item.md @@ -24,6 +24,14 @@ The public v0.6.0 tag was authorized and pushed by the pinned release wrapper at - cargo-dist workflow: `34016740704`, failed build jobs `101441745244`, `101441745248`, and `101441745351`. - Shipshape journal: `01M1TNW3SMN0XA347D1MG4518R`. +## Root cause evidence + +- The Linux failure is a jq parser incompatibility: jq 1.6 treats `include` as a reserved module keyword, so the filter variable `$include` never compiles. The production filter now uses `$ref_pattern` and is exercised unchanged in an Ubuntu 22.04 container reporting `jq-1.6`. +- `GET /repos/jarimustonen/taskfleet/rulesets/22234415` is publicly readable, but GitHub omits the privileged `bypass_actors` field from non-administrator responses. That is the shape returned with the workflow `GITHUB_TOKEN`; it is why jq 1.8 parsed the filter on the self-hosted macOS runner and then returned false without an API error. +- GitHub's ruleset endpoint requires repository **Administration: read** to return bypass actors. `GITHUB_TOKEN` has no grantable Administration permission, so changing workflow `contents` permissions cannot fix the redaction. +- A sanitized 2026-09-06 read using the Homebase SOPS-managed `HOMEBREW_TAP_TOKEN` returned HTTP 200 and exposed the required bypass-actor array for ruleset `22234415`; the token value was passed only through process environment and was neither printed nor persisted. See `credential-ruleset-read.json`. +- The generated cargo-dist workflow is tag-only and scopes that credential to its authorization step. The crates workflow scopes it to a dedicated `release-authorization` job guarded by `github.event_name == 'push'`; `publish-core` directly needs that job. Manual package inspection receives no credential, and neither publication workflow has a pull-request path to it. + ## Required outcome 1. Keep v0.6.0 immutable and never retag or reuse it. diff --git a/scripts/test-release-authorization.sh b/scripts/test-release-authorization.sh index 30d82e39..505bba28 100755 --- a/scripts/test-release-authorization.sh +++ b/scripts/test-release-authorization.sh @@ -3,6 +3,11 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" release="$repo_root/.github/workflows/release.yml" +publish="$repo_root/.github/workflows/publish-crates.yml" + +# The production policy parser is a separate fixture so malformed/redacted API +# responses cannot be hidden by the tag-authorizer's stubbed policy boundary. +"$repo_root/scripts/test-release-github-policy.sh" >/dev/null check_workflow() { local workflow="$1" @@ -13,6 +18,8 @@ check_workflow() { if grep -F 'custom-taskfleet-release-gate' "$workflow" >/dev/null; then return 1; fi [[ "$(grep -Fc 'name: "Require wrapper-authorized exact-main release tag"' "$workflow")" -ge 1 ]] || return 1 grep -F 'run: "./scripts/verify-release-tag-authorization.sh"' "$workflow" >/dev/null || return 1 + [[ "$(grep -Fc '"GH_TOKEN": "${{ secrets.HOMEBREW_TAP_TOKEN }}"' "$workflow")" == 1 ]] || return 1 + if grep -F '"GH_TOKEN": "${{ github.token }}"' "$workflow" >/dev/null; then return 1; fi grep -A8 '^ build-local-artifacts:' "$workflow" | grep -F 'needs:' >/dev/null || return 1 grep -A12 '^ build-local-artifacts:' "$workflow" | grep -F 'needs.plan.outputs.publishing == '\''true'\''' >/dev/null || return 1 grep -A8 '^ build-global-artifacts:' "$workflow" | grep -F -- '- build-local-artifacts' >/dev/null || return 1 @@ -23,6 +30,24 @@ check_workflow() { } check_workflow "$release" +# crates.io keeps its credential in a dedicated push-only job. +# workflow_dispatch remains credential-free and can only build package archives. +if grep -A12 '^on:' "$publish" | grep -Eq 'pull_request:'; then + echo "PR-triggered crates workflow unexpectedly exposes a release gate" >&2; exit 1 +fi +[[ "$(grep -Fc 'GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}' "$publish")" == 1 ]] || { + echo "crates tag gate does not receive the release credential exactly once" >&2; exit 1 +} +grep -A4 '^ release-authorization:' "$publish" | grep -F 'if: ${{ github.event_name == '\''push'\'' }}' >/dev/null || { + echo "crates authorization job is not restricted to tag push events" >&2; exit 1 +} +grep -A4 '^ publish-core:' "$publish" | grep -F 'release-authorization' >/dev/null || { + echo "crates publication does not depend on release authorization" >&2; exit 1 +} +if grep -F 'GH_TOKEN: ${{ github.token }}' "$publish" >/dev/null; then + echo "crates tag gate still uses the redacted workflow token" >&2; exit 1 +fi + # The authorization script itself is exercised, not merely grepped. Every # independently mutable coordinate must fail closed. tmp="$(mktemp -d "${TMPDIR:-/tmp}/taskfleet-release-auth.XXXXXX")" diff --git a/scripts/test-release-github-policy.sh b/scripts/test-release-github-policy.sh new file mode 100755 index 00000000..56a1a252 --- /dev/null +++ b/scripts/test-release-github-policy.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Exercise the production ruleset filter and its fail-closed diagnostics. +set -euo pipefail +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +tmp="$(mktemp -d "${TMPDIR:-/tmp}/taskfleet-policy-fixture.XXXXXX")" +trap 'rm -rf "$tmp"' EXIT +mkdir -p "$tmp/bin" +ln -s "$(command -v jq)" "$tmp/bin/jq" +ln -s "$(command -v mktemp)" "$tmp/bin/mktemp" +ln -s "$(command -v grep)" "$tmp/bin/grep" +ln -s "$(command -v tail)" "$tmp/bin/tail" +ln -s "$(command -v rm)" "$tmp/bin/rm" +cat >"$tmp/bin/gh" <<'STUB' +#!/bin/sh +id="${2##*/}" +case "${GH_FIXTURE_MODE:-valid}:$id" in + api-failure:*) echo 'gh: Resource not accessible by integration (HTTP 403)' >&2; exit 1 ;; + malformed:*) printf '%s\n' '{not-json'; exit 0 ;; + redacted:*) + jq -n --argjson id "$id" '{id:$id,name:"redacted",target:"tag",enforcement:"active", + conditions:{ref_name:{exclude:[],include:["refs/tags/**"]}},rules:[]}' ;; + mismatch:*) + jq -n --argjson id "$id" '{id:$id,name:"wrong SECRET_FIXTURE_VALUE",target:"tag",enforcement:"active", + conditions:{ref_name:{exclude:[],include:["refs/tags/**"]}},rules:[],bypass_actors:[]}' ;; + valid:22234415) + jq -n '{id:22234415,name:"Taskfleet release tags",target:"tag",enforcement:"active", + conditions:{ref_name:{exclude:[],include:["refs/tags/**"]}}, + rules:[{type:"creation"},{type:"update"},{type:"deletion"},{type:"non_fast_forward"}], + bypass_actors:[{actor_id:5,actor_type:"RepositoryRole",bypass_mode:"always"}]}' ;; + valid:22234417) + jq -n '{id:22234417,name:"Taskfleet release authorization refs",target:"branch",enforcement:"active", + conditions:{ref_name:{exclude:[],include:["refs/heads/taskfleet-release-authorizations/**"]}}, + rules:[{type:"creation"},{type:"update"},{type:"deletion"},{type:"non_fast_forward"}], + bypass_actors:[{actor_id:5,actor_type:"RepositoryRole",bypass_mode:"always"}]}' ;; + *) exit 97 ;; +esac +STUB +chmod +x "$tmp/bin/gh" + +run_policy() { + env -i PATH="$tmp/bin:/usr/bin:/bin" GH_FIXTURE_MODE="${1:-valid}" \ + "$repo_root/scripts/verify-release-github-policy.sh" +} +run_policy valid >/dev/null + +assert_failure() { + local mode="$1" expected="$2" output status + set +e + output="$(run_policy "$mode" 2>&1)" + status=$? + set -e + [[ "$status" -ne 0 ]] || { echo "$mode policy fixture unexpectedly passed" >&2; exit 1; } + grep -F "$expected" <<<"$output" >/dev/null || { + echo "$mode policy fixture lacked diagnostic: $expected" >&2 + printf '%s\n' "$output" >&2 + exit 1 + } + if grep -F 'SECRET_FIXTURE_VALUE' <<<"$output" >/dev/null; then + echo "$mode policy fixture leaked API response content" >&2 + exit 1 + fi +} +assert_failure api-failure 'API lookup failed (HTTP 403)' +assert_failure malformed 'API returned an unexpected JSON shape' +assert_failure redacted 'API response omits privileged bypass_actors' +assert_failure mismatch 'does not match the required protected policy' + +printf 'Taskfleet live release policy fixtures passed with %s\n' "$(jq --version)" diff --git a/scripts/validate-distribution-topology.sh b/scripts/validate-distribution-topology.sh index 671312b2..5ab49a03 100755 --- a/scripts/validate-distribution-topology.sh +++ b/scripts/validate-distribution-topology.sh @@ -99,8 +99,25 @@ grep -F './scripts/verify-release-tag-authorization.sh' .github/workflows/publis ./scripts/test-release-authorization.sh >/dev/null || { echo "structural release authorization fixtures failed" >&2; exit 2; } +[[ "$(grep -Fc '"GH_TOKEN": "${{ secrets.HOMEBREW_TAP_TOKEN }}"' .github/workflows/release.yml)" == 1 ]] || { + echo "generated authorization gate must use the administration-readable release credential" >&2; exit 2; +} [[ "$(grep -Fc 'token: ${{ secrets.HOMEBREW_TAP_TOKEN }}' .github/workflows/release.yml)" == 1 ]] || { - echo "generated workflow must use the one admitted tap secret" >&2; exit 2; + echo "generated workflow must use the one admitted tap checkout credential" >&2; exit 2; +} +if grep -F '"GH_TOKEN": "${{ github.token }}"' .github/workflows/release.yml >/dev/null; then + echo "generated authorization gate still uses the ruleset-redacted workflow token" >&2; exit 2 +fi +[[ "$(grep -Fc 'GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}' .github/workflows/publish-crates.yml)" == 1 ]] || { + echo "crates tag gate lacks its dedicated administration-readable credential" >&2; exit 2; +} +grep -A4 '^ release-authorization:' .github/workflows/publish-crates.yml | + grep -F 'if: ${{ github.event_name == '\''push'\'' }}' >/dev/null || { + echo "crates authorization credential is not isolated to push events" >&2; exit 2; +} +grep -A4 '^ publish-core:' .github/workflows/publish-crates.yml | + grep -F 'release-authorization' >/dev/null || { + echo "crates publication does not depend on authorization" >&2; exit 2; } [[ "$(grep -Fc 'runs-on: ${{ matrix.runner }}' .github/workflows/release.yml)" == 1 ]] || { echo "generated workflow lost the cargo-dist runner matrix" >&2; exit 2; diff --git a/scripts/verify-release-github-policy.sh b/scripts/verify-release-github-policy.sh index 491fc006..690a8c9c 100755 --- a/scripts/verify-release-github-policy.sh +++ b/scripts/verify-release-github-policy.sh @@ -3,16 +3,52 @@ # release tags or forging/moving wrapper authorization receipts. set -euo pipefail repo="jarimustonen/taskfleet" + check_ruleset() { - local id="$1" name="$2" target="$3" include="$4" json - json="$(gh api "repos/$repo/rulesets/$id")" || exit 1 - jq -e --argjson id "$id" --arg name "$name" --arg target "$target" --arg include "$include" ' + local id="$1" name="$2" target="$3" ref_pattern="$4" json api_error http_status + api_error="$(mktemp "${TMPDIR:-/tmp}/taskfleet-ruleset-api.XXXXXX")" + if ! json="$(gh api "repos/$repo/rulesets/$id" 2>"$api_error")"; then + # Report only the HTTP status emitted by gh. Never echo an API response, + # request headers, or credential-bearing environment data. + http_status="$(grep -Eo 'HTTP [0-9]{3}' "$api_error" | tail -1 || true)" + rm -f "$api_error" + echo "release ruleset $id API lookup failed${http_status:+ ($http_status)}" >&2 + return 1 + fi + rm -f "$api_error" + + jq -e 'type == "object" and + (.id | type) == "number" and (.name | type) == "string" and + (.target | type) == "string" and (.enforcement | type) == "string" and + (.conditions.ref_name.exclude | type) == "array" and + (.conditions.ref_name.include | type) == "array" and + (.rules | type) == "array" + ' <<<"$json" >/dev/null || { + echo "release ruleset $id API returned an unexpected JSON shape" >&2 + return 1 + } + + # GitHub deliberately omits bypass_actors from the public/non-admin response. + # Reading this field requires repository Administration (read). A normal + # workflow GITHUB_TOKEN cannot be granted that permission, so a redacted + # response is an authorization failure rather than a policy mismatch. + jq -e 'has("bypass_actors") and (.bypass_actors | type) == "array"' \ + <<<"$json" >/dev/null || { + echo "release ruleset $id API response omits privileged bypass_actors; credential requires repository Administration read" >&2 + return 1 + } + + jq -e --argjson id "$id" --arg name "$name" --arg target "$target" --arg ref_pattern "$ref_pattern" ' .id == $id and .name == $name and .target == $target and .enforcement == "active" and - .conditions.ref_name == {exclude:[],include:[$include]} and + .conditions.ref_name == {exclude:[],include:[$ref_pattern]} and .bypass_actors == [{actor_id:5,actor_type:"RepositoryRole",bypass_mode:"always"}] and ([.rules[].type] | sort) == (["creation","deletion","non_fast_forward","update"] | sort) - ' <<<"$json" >/dev/null + ' <<<"$json" >/dev/null || { + echo "release ruleset $id does not match the required protected policy" >&2 + return 1 + } } + check_ruleset 22234415 "Taskfleet release tags" tag 'refs/tags/**' check_ruleset 22234417 "Taskfleet release authorization refs" branch \ "refs/heads/taskfleet-release-authorizations/**" From 0ab084f84dc50e14aef3474df2310fdf08bc5ebf Mon Sep 17 00:00:00 2001 From: Jari Mustonen Date: Sun, 6 Sep 2026 10:12:08 +0300 Subject: [PATCH 2/4] docs: note release gate portability fix Refs-Issue: @taskfleet-release-gate-ci-portability --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12787d2e..56375a59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Changed ### Fixed + +- Make release-policy verification compatible with hosted jq 1.6 and supply its administration-readable credential only to tag-push authorization gates. ## [0.6.0] - 2026-09-06 From a8ab5d0e3475f83dbf116cbe71300316308b195f Mon Sep 17 00:00:00 2001 From: Jari Mustonen Date: Sun, 6 Sep 2026 10:19:34 +0300 Subject: [PATCH 3/4] test: pin release policy fixture to jq 1.6 Refs-Issue: @taskfleet-release-gate-ci-portability --- .github/workflows/ci.yml | 12 +++++++++--- scripts/test-release-github-policy.sh | 3 ++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dee45790..9d224f9d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -163,11 +163,17 @@ jobs: test "$($dist_bin --version)" = 'cargo-dist 0.28.2' echo "DIST_BIN=$dist_bin" >> "$GITHUB_ENV" - run: '"$DIST_BIN" generate --check' - - name: Exercise release policy parser on hosted jq 1.6 + - name: Exercise release policy parser with pinned jq 1.6 shell: bash run: | - test "$(jq --version)" = jq-1.6 - ./scripts/test-release-github-policy.sh + jq_bin="$RUNNER_TEMP/jq-1.6" + curl --proto '=https' --tlsv1.2 -LsSf \ + https://github.com/jqlang/jq/releases/download/jq-1.6/jq-linux64 \ + -o "$jq_bin" + echo 'af986793a515d500ab2d35f8d2aecd656e764504b789b66d7e1a0b727a124c44 '"$jq_bin" | sha256sum --check + chmod +x "$jq_bin" + test "$($jq_bin --version)" = jq-1.6 + REAL_JQ="$jq_bin" ./scripts/test-release-github-policy.sh - run: ./scripts/test-release-authorization.sh - name: Validate exact generated release plan and coherent activation state shell: bash diff --git a/scripts/test-release-github-policy.sh b/scripts/test-release-github-policy.sh index 56a1a252..b480a9ce 100755 --- a/scripts/test-release-github-policy.sh +++ b/scripts/test-release-github-policy.sh @@ -5,7 +5,8 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" tmp="$(mktemp -d "${TMPDIR:-/tmp}/taskfleet-policy-fixture.XXXXXX")" trap 'rm -rf "$tmp"' EXIT mkdir -p "$tmp/bin" -ln -s "$(command -v jq)" "$tmp/bin/jq" +jq_bin="${REAL_JQ:-$(command -v jq)}" +ln -s "$jq_bin" "$tmp/bin/jq" ln -s "$(command -v mktemp)" "$tmp/bin/mktemp" ln -s "$(command -v grep)" "$tmp/bin/grep" ln -s "$(command -v tail)" "$tmp/bin/tail" From 1eacafef5d3e92220448ae35e5989d5b16b7ea8f Mon Sep 17 00:00:00 2001 From: Jari Mustonen Date: Sun, 6 Sep 2026 10:26:36 +0300 Subject: [PATCH 4/4] docs(issue): close release gate portability fix Fixes-Issue: @taskfleet-release-gate-ci-portability --- .../item.md | 41 +++++++++++++++---- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/issues/taskfleet-release-gate-ci-portability/item.md b/issues/taskfleet-release-gate-ci-portability/item.md index 7979a1d8..fbc337de 100644 --- a/issues/taskfleet-release-gate-ci-portability/item.md +++ b/issues/taskfleet-release-gate-ci-portability/item.md @@ -2,12 +2,19 @@ created: 2026-09-06 updated: 2026-09-06 type: bug -status: open +status: fixed priority: high related: ['@taskfleet-release-0-6-0'] lane: taskfleet-rename lane_seq: 111 collision: [scripts/verify-release-github-policy.sh, .github/workflows/release.yml, .github/workflows/publish-crates.yml] +closed: 2026-09-06 +closed_by: pi +commits: +- hash: 8ad34689a4a23e3d354b37aafcf5099c0b6c448c + summary: fix release gate credentials and diagnostics +- hash: a8ab5d0e3475f83dbf116cbe71300316308b195f + summary: pin jq 1.6 fixture and pass exact candidate CI --- # Release gate fails on CI jq and workflow token @@ -41,11 +48,29 @@ The public v0.6.0 tag was authorized and pushed by the pinned release wrapper at 5. Validate through an exact candidate PR and merged-main CI without tagging or publishing. 6. Document v0.6.0 as an unpublished burned tag and prepare a new patch release (v0.6.1) only through a newly sealed wrapper plan. -## Definition of Done +## Acceptance Criteria -- [ ] Both authorization paths pass with the exact runner jq/tool/token topology used by tag workflows. -- [ ] Missing, malformed, inaccessible, or mismatched rulesets still fail closed. -- [ ] PRs cannot access release credentials or execute publication. -- [ ] Full green gate and exact-SHA CI evidence are recorded. -- [ ] No v0.6.0 artifact/package/formula was published and no tag was moved. -- [ ] The fix is ready for a fresh v0.6.1 wrapper transaction. +- [x] Both authorization paths pass with the exact runner jq/tool/token topology used by tag workflows. +- [x] Missing, malformed, inaccessible, or mismatched rulesets still fail closed. +- [x] PRs cannot access release credentials or execute publication. +- [x] Full green gate and exact-SHA CI evidence are recorded. +- [x] No v0.6.0 artifact/package/formula was published and no tag was moved. +- [x] The fix is ready for a fresh v0.6.1 wrapper transaction. + +## Validation + +- Exact candidate: `a8ab5d0e3475f83dbf116cbe71300316308b195f`. +- Same-repository PR: [#3](https://github.com/jarimustonen/taskfleet/pull/3). +- Exact-SHA CI: [34018842931](https://github.com/jarimustonen/taskfleet/actions/runs/34018842931), green across hosted Linux/macOS, self-hosted ARM64 macOS, jq 1.6 release topology, MSRV, clippy, docs, deny, snapshots, and tests. +- The jq fixture passed against checksum-pinned `jq-1.6` (`af986793…a124c44`) and local jq 1.8.2. API failure, malformed shape, privilege-redacted shape, and policy mismatch fixtures each produced a distinct non-secret diagnostic and failed closed. +- Homebase's SOPS-managed release credential read both live rulesets with HTTP 200 and exposed `bypass_actors`; `credential-ruleset-read.json` contains only sanitized metadata. +- cargo-dist 0.28.2 `generate --check`, exact plan/topology validation, the pinned Homebrew 6.0.21 disposable distribution drill, actionlint structure, all release authorization/wrapper/publish fixtures, and the exact Shipshape 0.10.1 migration protocol passed. +- Full local gate passed: fmt, clippy warnings-as-errors, release nextest, doctests, and rustdoc warnings-as-errors. The all-workspace release nextest suite also passed with the stripped declared PATH. +- Postflight: all three crates remain without v0.6.0; no v0.6.0 GitHub Release exists; canonical and old tap heads remain `db12bb163e47617f0b941a35d3896b6ba0548892` and `85ce830378f38cf17283efddd966d5754354e403` respectively. +- No tag, authorization ref, ruleset, tap, registry, installation, or release journal was mutated by this fix. + +## Resolution + +### 2026-09-06T07:26:15Z · @pi + +jq 1.6 portability, administration-readable tag-gate credentials, fail-closed diagnostics, exact cargo-dist generation, and candidate CI 34018842931 are verified; v0.6.0 remains unpublished and immutable.