Dashboard › publish › Session 0A2neX6Lse1i
0A2neX6Lse1iBFBA4Let's see if we can implement https://github.com/getsentry/publish/issues/9355
Do a thorough research, and feasibility study before moving to implementation.
I’ll examine the issue, current publish flow, adjacent parsing and validation code, and test coverage first. I’ll then assess scope, risks, and an implementation path before changing files.
[
{
"content": "Retrieve issue requirements and repository context",
"status": "in_progress",
"priority": "high"
},
{
"content": "Trace affected release parsing and workflow paths",
"status": "pending",
"priority": "high"
},
{
"content": "Assess feasibility, risks, and test strategy",
"status": "pending",
"priority": "high"
},
{
"content": "Present implementation-ready recommendation",
"status": "pending",
"priority": "medium"
}
]
<path>/home/byk/Code/getsentry/publish</path> <type>directory</type> <entries> .eslintrc.js .git/ .github/ .gitignore AGENTS.md auto-approve-repos.txt docs/ LICENSE node_modules/ package.json README.md src/ vitest.config.js yarn.lock (14 entries) </entries>
author: BYK association: member edited: false status: none -- cc @aldy505 @timfish @stephanie-anderson --
/home/byk/Code/getsentry/publish/src/publish/update-issue.js /home/byk/Code/getsentry/publish/src/publish/inputs.js /home/byk/Code/getsentry/publish/src/publish/post-workflow-details.js /home/byk/Code/getsentry/publish/src/libs/github.js /home/byk/Code/getsentry/publish/src/publish/post-result.js /home/byk/Code/getsentry/publish/src/libs/__tests__/github.js /home/byk/Code/getsentry/publish/src/modules/update-issue.js /home/byk/Code/getsentry/publish/src/modules/process-end-state.js /home/byk/Code/getsentry/publish/src/modules/post-workflow-details.js /home/byk/Code/getsentry/publish/src/modules/details-from-context.js /home/byk/Code/getsentry/publish/src/modules/__tests__/details-from-context.js /home/byk/Code/getsentry/publish/src/modules/__tests__/post-workflow-details.js /home/byk/Code/getsentry/publish/src/modules/__tests__/process-end-state.js /home/byk/Code/getsentry/publish/src/modules/__tests__/update-issue.js
<path>/home/byk/Code/getsentry/publish/package.json</path>
<type>file</type>
<content>
1: {
2: "name": "publish",
3: "private": true,
4: "version": "0.0.1",
5: "description": "Approval-based publishing system for Sentry",
6: "main": "index.js",
7: "repository": "git@github.com:getsentry/publish.git",
8: "author": "Sentry Open Source <oss@sentry.io>",
9: "license": "Apache-2.0",
10: "volta": {
11: "node": "24.0.0",
12: "yarn": "1.22.22"
13: },
14: "resolutions": {
15: "undici": "^6.23.0"
16: },
17: "scripts": {
18: "test": "vitest run",
19: "test:watch": "vitest",
20: "lint": "eslint src .github --ignore-pattern '!.github'",
21: "prettier": "prettier --write src"
22: },
23: "devDependencies": {
24: "eslint": "^8.9.0",
25: "eslint-config-prettier": "^8.3.0",
26: "eslint-plugin-yml": "^0.13.0",
27: "prettier": "^2.2.1",
28: "vitest": "^4.1.0"
29: },
30: "dependencies": {
31: "@actions/core": ...
{"assignees":[{"id":"MDQ6VXNlcjEyNjc4MA==","login":"BYK","name":"Burak Yigit Kaya","databaseId":126780}],"author":{"id":"MDQ6VXNlcjEyNjc4MA==","is_bot":false,"login":"BYK","name":"Burak Yigit Kaya"},"body":"Currently, anyone with triage access or more to this repo can approve releases. This is mostly okay but it creates friction when it comes to contractors who has write access to select SDK repos but cannot approve or retract release issues for their own repos.\n\nProposal:\n\nLeverage the fact that we already have ownership and permission information both in GitHub and in getsentry/security-as-code repos.\n\nCaveats:\n\n- getsentry/security-as-code is a private repo and its contents should stay private whereas the publish repo is public. Any implementation using that repo should make sure this is the case\n- GitHub may not reveal access rights of individuals on all repos but I think this should be doable with org-level access tokens on the bot accounts. ...
<path>/home/byk/Code/getsentry/publish/src/publish/update-issue.js</path>
<type>file</type>
<content>
1: const { updateIssue } = require('../modules/update-issue.js');
2: const {getGitHubToken} = require('../libs/github');
3: const github = require('@actions/github');
4:
5: const context = github.context;
6: const octokit = github.getOctokit(getGitHubToken());
7: const inputs = JSON.parse(process.env.PUBLISH_ARGS);
8:
9: updateIssue({ context, octokit, inputs });
(End of file - total 9 lines)
</content>
<path>/home/byk/Code/getsentry/publish/src/modules/update-issue.js</path>
<type>file</type>
<content>
1: const fs = require("fs");
2: const {
3: TARGETS_SECTION_PARSER_REGEX,
4: TARGETS_PARSER_REGEX,
5: } = require("./details-from-context");
6:
7: async function updateTargets({octokit, version, publishRepo, issue_number}) {
8: const CRAFT_STATE_FILE_PATH = `${process.env.GITHUB_WORKSPACE}/__repo__/.craft-publish-${version}.json`;
9:
10: if (!fs.existsSync(CRAFT_STATE_FILE_PATH)) {
11: return;
12: }
13:
14: const issueRequest = octokit.rest.issues.get({
15: ...publishRepo,
16: issue_number,
17: });
18:
19: const craftStateRequest = fs.promises
20: .readFile(CRAFT_STATE_FILE_PATH, { encoding: "utf-8" })
21: .then((data) => JSON.parse(data));
22:
23: const [{ data: issue }, craftState] = await Promise.all([
24: issueRequest,
25: craftStateRequest,
26: ]);
27:
28: const newIssueBody = transformIssueBody(craftState, issue.body);
29: ...
<path>/home/byk/Code/getsentry/publish/src/modules/details-from-context.js</path>
<type>file</type>
<content>
1: /**
2: * Matches the entire "Targets" section of a github publish issue body.
3: */
4: const TARGETS_SECTION_PARSER_REGEX =
5: /^(?!### Targets$\s)(?: *- \[[ xX]\] \S+\s*$(?:\r?\n)?)+/m;
6:
7: /**
8: * Matches all targets of a github publish issue body in a section that was already matched and extracted with `TARGETS_PARSER_REGEX`.
9: * The "id" of the targets is captured within a capture group.
10: */
11: const TARGETS_PARSER_REGEX = /^\s*- \[[ x]\] (\S+)/gim;
12:
13: /**
14: * Matches checked targets of a github publish issue body in a section that was already matched and extracted with `TARGETS_PARSER_REGEX`.
15: * The "id" of the targets is captured within a capture group.
16: */
17: const CHECKED_TARGETS_PARSER_REGEX = /^\s*- \[x\] (\S+)/gim;
18:
19: async function detailsFromContext({ context }) {
20: if (!context || !context.payload || ...
<path>/home/byk/Code/getsentry/publish/src/modules/process-end-state.js</path>
<type>file</type>
<content>
1: const Sentry = require("@sentry/node");
2:
3: async function processEndState({ context, octokit, inputs, status }) {
4: const { repo, version } = inputs;
5: const { repo: publishRepo, runId: run_id } = context;
6: const { number: issue_number } = context.payload.issue;
7: const workflowInfo = (
8: await octokit.rest.actions.getWorkflowRun({
9: ...publishRepo,
10: run_id,
11: })
12: ).data;
13:
14: const details = {
15: repo,
16: version,
17: publishRepo,
18: run_id,
19: issue_number,
20: workflowInfo,
21: status,
22: };
23:
24: await postIssueComment({
25: octokit,
26: details,
27: });
28:
29: if (status === "success") {
30: await octokit.rest.issues.update({
31: ...publishRepo,
32: issue_number,
33: state: "closed",
34: });
35: }
36:
37: await reportSession({ ...
<path>/home/byk/Code/getsentry/publish/src/publish/inputs.js</path>
<type>file</type>
<content>
1: const github = require('@actions/github');
2: const core = require('@actions/core');
3: const { detailsFromContext } = require('../modules/details-from-context');
4:
5: async function inputs() {
6: const result = await detailsFromContext({
7: context: github.context,
8: });
9: core.setOutput('result', result);
10: }
11:
12:
13: inputs();
(End of file - total 13 lines)
</content>
<path>/home/byk/Code/getsentry/publish/src/libs/github.js</path>
<type>file</type>
<content>
1: /**
2: * This is a helper function that throws a useful error message if the
3: * workflow environment is not configured correctly.
4: *
5: * @returns string
6: */
7: function getGitHubToken() {
8: if (!process.env.GITHUB_TOKEN) {
9: throw new Error('No "GITHUB_TOKEN" environment variable found. ' +
10: 'Please ensure the workflow is configured correctly');
11: }
12: return process.env.GITHUB_TOKEN;
13: }
14:
15: module.exports = {
16: getGitHubToken,
17: };
(End of file - total 17 lines)
</content>
No files found
<path>/home/byk/Code/getsentry/publish/.github</path> <type>directory</type> <entries> CODEOWNERS workflows/ (2 entries) </entries>
You should have access to security-as-code repo locally to check its format btw. It may also be checked out at ~/Code/getsentry/security-as-code
The repository has no authorization check today: labelling or unlabelling an issue drives the workflow directly. I’ll now map those trigger paths and inspect security-as-code’s live schema and CI interfaces, without exposing any private configuration.
[
{
"content": "Retrieve issue requirements and repository context",
"status": "completed",
"priority": "high"
},
{
"content": "Trace affected release parsing and workflow paths",
"status": "in_progress",
"priority": "high"
},
{
"content": "Assess feasibility, risks, and test strategy",
"status": "pending",
"priority": "high"
},
{
"content": "Present implementation-ready recommendation",
"status": "pending",
"priority": "medium"
}
]
<path>/home/byk/Code/getsentry/publish/.github/workflows/publish.yml</path>
<type>file</type>
<content>
1: name: Publish
2: on:
3: issues:
4: types: [labeled]
5:
6: concurrency:
7: # Use the issue title (e.g. "publish: getsentry/foo@1.2.3") so duplicate
8: # issues for the same repo@version share a concurrency group.
9: group: ${{ github.event.issue.title }}
10: cancel-in-progress: false
11:
12: permissions:
13: contents: read
14: issues: write
15: packages: write
16:
17: jobs:
18: # When accepted is added to a publish issue:
19: # - Add ci-pending (and remove ci-failed if retrying)
20: # - Enable the poller via CI_POLLER_HAS_PENDING=true
21: # - Comment on the issue
22: # - Trigger the poller immediately so we don't wait for the next cron tick
23: # The publish job below requires ci-ready, so it will not fire until the
24: # poller flips ci-pending → ci-ready (which also prevents publishing without
25: # CI verification in the ...
<path>/home/byk/Code/getsentry/publish/.github/workflows/auto-approve.yml</path>
<type>file</type>
<content>
1: name: auto-approve non-sdks
2: on:
3: issues:
4: types: [opened]
5:
6: permissions:
7: contents: read
8:
9: jobs:
10: auto-approve:
11: runs-on: ubuntu-latest
12: if: |
13: (github.actor == 'sentry-release-bot[bot]' || github.actor == 'getsantry[bot]') &&
14: startsWith(github.event.issue.title, 'publish: ')
15: steps:
16: - uses: actions/checkout@v4
17: with:
18: sparse-checkout: auto-approve-repos.txt
19: sparse-checkout-cone-mode: false
20: - name: Get auth token
21: id: token
22: uses: actions/create-github-app-token@v3
23: with:
24: client-id: ${{ vars.SENTRY_INTERNAL_APP_ID }}
25: private-key: ${{ secrets.SENTRY_INTERNAL_APP_PRIVATE_KEY }}
26: - name: Auto-approve if repo is in the list
27: env:
28: GH_TOKEN: ${{ ...
<path>/home/byk/Code/getsentry/publish/.github/workflows/ci-poller.yml</path> <type>file</type> <content> 1: name: CI Status Poller 2: 3: on: 4: schedule: 5: - cron: "*/5 * * * *" 6: workflow_dispatch: 7: inputs: 8: attempt: 9: description: Self-dispatch attempt counter (internal use) 10: required: false 11: default: "0" 12: 13: permissions: 14: contents: read 15: issues: read 16: 17: jobs: 18: check-ci: 19: runs-on: ubuntu-latest 20: environment: production 21: # Skip entirely (no runner provisioned) when there's nothing to check. 22: # Set to "true" by ci-pending.yml, reset to "false" here when done. 23: # Always allow workflow_dispatch for manual recovery. 24: if: vars.CI_POLLER_HAS_PENDING == 'true' || github.event_name == 'workflow_dispatch' 25: concurrency: 26: group: ci-status-poller 27: cancel-in-progress: false 28: steps: 29: # sentry-internal-app token for label changes on ...
<path>/home/byk/Code/getsentry/security-as-code</path> <type>directory</type> <entries> .activate.sh .claude/ .cursor/ .deactivate.sh .editorconfig .envrc .git/ .github/ .gitignore .pre-commit-config.yaml .prettierrc.json5 .terraform-version .tflint.hcl auth-as-terraformer.sourceme CODEOWNERS doc/ iac-security/ lib/ pyproject.toml rbac/ README.md requirements.d/ sbin/ (23 entries) </entries>
/home/byk/Code/getsentry/security-as-code/rbac/terraform.tfvars /home/byk/Code/getsentry/security-as-code/rbac/README.md /home/byk/Code/getsentry/security-as-code/rbac/.terraform.lock.hcl /home/byk/Code/getsentry/security-as-code/rbac/.envrc /home/byk/Code/getsentry/security-as-code/rbac/notes/rollout-timeline.md /home/byk/Code/getsentry/security-as-code/rbac/module/terraformers/_init.tf /home/byk/Code/getsentry/security-as-code/rbac/module/terraformers/main.tf /home/byk/Code/getsentry/security-as-code/rbac/module/group/_init.tf /home/byk/Code/getsentry/security-as-code/rbac/module/group/main.tf /home/byk/Code/getsentry/security-as-code/rbac/module/group/_variables.tf /home/byk/Code/getsentry/security-as-code/rbac/module/group/README.md /home/byk/Code/getsentry/security-as-code/rbac/module/group/team/main.tf /home/byk/Code/getsentry/security-as-code/rbac/module/group/team/README.md /home/byk/Code/getsentry/security-as-code/rbac/module/group/sa-role/main.tf /home/byk/Code/getsentry/secu...
Found 100 matches (more matches available)
/home/byk/Code/getsentry/security-as-code/rbac/module/github/team/main.tf:
Line 4: role = "github-${github_team._.slug}"
Line 11: resource "github_team" "_" {
Line 12: name = var.github_team
Line 14: privacy = var.github_team_privacy
Line 15: parent_team_id = var.github_team_parent
Line 18: variable "github_team" {
Line 25: variable "github_team_parent" {
Line 29: variable "github_team_privacy" {
/home/byk/Code/getsentry/security-as-code/rbac/lib/github/team-members-sync/teams.tf:
Line 1: data "github_organization_teams" "_" {
Line 9: for team in data.github_organization_teams._.teams :
Line 14: resource "github_team_members" "_" {
/home/byk/Code/getsentry/security-as-code/rbac/module/group/main.tf:
Line 39: # FIXME: uncomment after release of ...
/home/byk/Code/getsentry/security-as-code/rbac/env/prod-github/moved.tf /home/byk/Code/getsentry/security-as-code/rbac/env/prod-github/sac-terraformer /home/byk/Code/getsentry/security-as-code/rbac/env/prod-github/.terraform.lock.hcl /home/byk/Code/getsentry/security-as-code/rbac/env/prod-github/_backend.tf /home/byk/Code/getsentry/security-as-code/rbac/env/prod-github/README.md /home/byk/Code/getsentry/security-as-code/rbac/env/prod-github/org/variables.yml /home/byk/Code/getsentry/security-as-code/rbac/env/prod-github/org/ruleset_warden-scan.tf /home/byk/Code/getsentry/security-as-code/rbac/env/prod-github/org/ruleset_secret-scanning.tf /home/byk/Code/getsentry/security-as-code/rbac/env/prod-github/org/ruleset_dependency-review.tf /home/byk/Code/getsentry/security-as-code/rbac/env/prod-github/org/_init.tf /home/byk/Code/getsentry/security-as-code/rbac/env/prod-github/org/variables.tf /home/byk/Code/getsentry/security-as-code/rbac/env/prod-github/team/events-analytics-platform.tf /hom...
<path>/home/byk/Code/getsentry/security-as-code/rbac/env/prod-github/team/release-approvers.tf</path>
<type>file</type>
<content>
1: module "github-team--release-approvers" {
2: source = "../../../module/github/team"
3: github_team = "Release Approvers"
4: github_team_parent = "engineering"
5: description = <<-EOF
6: People authorized to approve releases from the getsentry/publish repo
7: EOF
8: teams = [
9: "team-release-approvers@sentry.io",
10: ]
11: }
12:
(End of file - total 12 lines)
</content>
Found 7 matches
/home/byk/Code/getsentry/security-as-code/rbac/env/prod-github/diagram.md:
Line 134: │ ├──release-approvers
/home/byk/Code/getsentry/security-as-code/rbac/env/prod/team/engineering/release-approvers.tf:
Line 1: module "team-group--release-approvers" {
Line 4: team = "release-approvers"
Line 5: display_name = "Team Release Approvers"
/home/byk/Code/getsentry/security-as-code/rbac/env/prod-github/team/release-approvers.tf:
Line 1: module "github-team--release-approvers" {
Line 3: github_team = "Release Approvers"
Line 9: "team-release-approvers@sentry.io",
<path>/home/byk/Code/getsentry/security-as-code/rbac/module/github/team/main.tf</path>
<type>file</type>
<content>
1: module "role" {
2: source = "../../group/role"
3:
4: role = "github-${github_team._.slug}"
5: description = var.description
6: members = concat(var.members, var.sub_dynamic_group)
7: teams = var.teams
8: sub-roles = var.sub-roles
9: }
10:
11: resource "github_team" "_" {
12: name = var.github_team
13: description = var.description
14: privacy = var.github_team_privacy
15: parent_team_id = var.github_team_parent
16: }
17:
18: variable "github_team" {
19: type = string
20: }
21: variable "description" {
22: type = string
23: default = null
24: }
25: variable "github_team_parent" {
26: type = string
27: default = null
28: }
29: variable "github_team_privacy" {
30: type = string
31: default = "closed"
32: }
33: variable "members" {
34: type = list(string)
35: default = []
36: }
37: ...
No files found
Found 5 matches
/home/byk/Code/getsentry/security-as-code/rbac/env/prod-github/moved.tf:
Line 2: from = module.repo.github_repository_collaborators._["sentry-for-claude"]
Line 3: to = module.repo.github_repository_collaborators._["sentry-for-ai"]
/home/byk/Code/getsentry/security-as-code/rbac/env/prod-github/repo/_collaborator.tf:
Line 118: resource "github_repository_collaborators" "_" {
Line 169: resource "github_team_repository" "_" {
Line 197: resource "github_team_repository" "gaming" {
<path>/home/byk/Code/getsentry/security-as-code/rbac/env/prod-github/repo/_collaborator.tf</path>
<type>file</type>
<content>
1: # See docs for more details on what these are for: https://www.notion.so/sentry/Technical-details-for-managing-Repo-access-and-outside-collaborators-2e9eb9c4fa164af08c8c12137a1bd871?pvs=4
2: locals {
3: # hardcoding this here as there's no easy way to pull this with terraform
4: custom_role_map = {
5: "security" = "triage"
6: }
7: # using this to compare permission levels for custom roles
8: permission_level = {
9: "admin" = 6
10: "elevated_bot" = 5
11: "maintain" = 4
12: "write" = 3
13: "triage" = 2
14: "read" = 1
15: }
16: # mapping team_slug to team_id
17: team_id_map = { for team in data.github_organization_teams.all.teams : team.slug => team.id }
18: # translate human readable permission to terms github uses
19: permission_map = {
20: "admin" = "admin"
21: ...
# REST API endpoints for collaborators
Use the REST API to manage collaborators for a repository.
> [!NOTE]
> Most endpoints use `Authorization: Bearer <YOUR-TOKEN>` and `Accept: application/vnd.github+json` headers, plus `X-GitHub-Api-Version: 2026-03-10`. Curl examples below omit these standard headers for brevity.
## List repository collaborators
```
GET /repos/{owner}/{repo}/collaborators
```
For organization-owned repositories, the list of collaborators includes outside collaborators, organization members that are direct collaborators, organization members with access through team memberships, organization members with access through default organization permissions, and organization owners.
The permissions hash returned in the response contains the base role permissions of the collaborator. The role_name is the highest role assigned to the collaborator after considering all sources of grants, including: repo, teams, organization, and enterprise. ...
Found 3 matches /home/byk/Code/getsentry/publish/node_modules/@octokit/plugin-rest-endpoint-methods/dist-web/index.js: Line 1528: getCollaboratorPermissionLevel: [ /home/byk/Code/getsentry/publish/node_modules/@octokit/plugin-rest-endpoint-methods/dist-src/generated/endpoints.js: Line 1524: getCollaboratorPermissionLevel: [ /home/byk/Code/getsentry/publish/node_modules/@octokit/plugin-rest-endpoint-methods/dist-node/index.js: Line 1555: getCollaboratorPermissionLevel: [
{"permission":"admin","role_name":"admin"}
{"permission":"read","role_name":"read"}
Invalid search query "( repo:\"getsentry/publish (accepted OR approv*) (permission OR triage OR retract)\" ) type:issue". The listed users and repositories cannot be searched either because the resources do not exist or you do not have permission to view them.
[{"number":9167,"state":"closed","title":"publish: getsentry/craft@2.29.0","updatedAt":"2026-08-07T17:04:27Z","url":"https://github.com/getsentry/publish/issues/9167"},{"number":9219,"state":"closed","title":"publish: getsentry/sentry-refactor-tasks@0.2.0","updatedAt":"2026-08-12T04:57:55Z","url":"https://github.com/getsentry/publish/issues/9219"},{"number":9332,"state":"closed","title":"publish: getsentry/devservices@1.5.0","updatedAt":"2026-08-25T20:50:05Z","url":"https://github.com/getsentry/publish/issues/9332"},{"number":9207,"state":"closed","title":"publish: getsentry/dotagents@3.0.0","updatedAt":"2026-08-11T17:47:05Z","url":"https://github.com/getsentry/publish/issues/9207"},{"number":8949,"state":"closed","title":"publish: getsentry/sentry-unreal@1.17.0","updatedAt":"2026-07-22T17:34:19Z","url":"https://github.com/getsentry/publish/issues/8949"},{"number":8922,"state":"closed","title":"publish: ...
<path>/home/byk/Code/getsentry/publish/.github/workflows/test.yml</path>
<type>file</type>
<content>
1: name: Test
2: on:
3: push:
4: branches:
5: - main
6: pull_request:
7:
8: permissions:
9: contents: read
10:
11: defaults:
12: run:
13: shell: bash
14: jobs:
15: unit-test:
16: runs-on: ubuntu-latest
17: name: unit tests
18: steps:
19: - uses: actions/checkout@v6
20: - uses: actions/setup-node@v6
21: with:
22: node-version: 24
23: - uses: actions/cache@v4
24: id: cache
25: with:
26: path: node_modules
27: key: ${{ runner.os }}-node_modules-${{ hashFiles('package.json', 'yarn.lock') }}
28:
29: - name: Install Dependencies
30: if: steps.cache.outputs.cache-hit != 'true'
31: run: yarn install --frozen-lockfile
32:
33: - name: Test
34: run: yarn test
(End of file - total 34 lines)
</content>
<path>/home/byk/Code/getsentry/publish/src/modules/__tests__/details-from-context.js</path>
<type>file</type>
<content>
1: import { test, expect } from "vitest";
2:
3: const { detailsFromContext } = require("../details-from-context.js");
4:
5: const inputsArgs = {
6: context: {
7: repo: { owner: "getsentry", repo: "publish" },
8: payload: {
9: issue: {
10: number: "223",
11: title: "publish: getsentry/sentry@21.3.1",
12: body: `
13: Requested by: @BYK
14:
15: Merge target: custom-branch
16:
17: Quick links:
18: - [View changes](https://github.com/getsentry/sentry/compare/21.3.0...refs/heads/releases/21.3.1)
19: - [View check runs](https://github.com/getsentry/sentry/commit/7e5ca7ed5581552de066e2a8bc295b8306be38ac/checks/)
20:
21: Assign the **accepted** label to this issue to approve the release. ...
# Permissions required for GitHub Apps For each permission granted to a GitHub App, these are the REST API endpoints that the app can use. ## About GitHub App permissions GitHub Apps are created with a set of permissions. Permissions define what resources the GitHub App can access via the API. For more information, see [Choosing permissions for a GitHub App](/en/apps/creating-github-apps/registering-a-github-app/choosing-permissions-for-a-github-app). To help you choose the correct permissions, you will receive the `X-Accepted-GitHub-Permissions` header in the REST API response. The header will tell you what permissions are required in order to access the endpoint. For more information, see [Troubleshooting the REST API](/en/rest/using-the-rest-api/troubleshooting-the-rest-api#resource-not-accessible). These permissions are required to access private resources. Some endpoints can also be used to access public resources without these permissions. ...
<task id="ses_fbc79cba8ffe9nkEEUn45KiKAb" state="completed"> <task_result> - **Required GitHub App permission:** repository **Metadata: read**. No additional permission is listed; **Administration is not required**. - **IAT accepted:** **Yes** — the endpoint lists `UAT, IAT`. - **Caveats:** The installation must have access to the target repository. The table states requirements for private resources; public-resource behavior can differ. GitHub reports the user’s effective highest role across repository, team, organization, and enterprise grants, not its source. Evidence: `/home/byk/.local/share/opencode/tool-output/tool_043860ce0001dHKJARbna8GGVi:1135-1143` (and token definitions at `:9-15`). </task_result> </task>
Found 1 matches /home/byk/Code/getsentry/security-as-code/rbac/env/prod-github/repo/publish.yml: Line 1: repo_slug: publish
<path>/home/byk/Code/getsentry/security-as-code/rbac/env/prod-github/repo/publish.yml</path> <type>file</type> <content> 1: repo_slug: publish 2: tag: prod 3: teams: 4: write: 5: - engineering 6: triage: 7: - bots-write 8: admin: 9: - releng (End of file - total 9 lines) </content>
de698e31 Update publish repo team permissions (#2394) 43150992 chores(gh): Clean up on release-bot (#2159) 4e6f05e7 feat(rbac): Rename Web Frontend SDK to JavaScript SDKs (#2033) 53cf1915 add profiling team to list of teams with publish permissions 8b625e9f team-gdx > gdx b53a151b Add SDK teams to publish triage e9ba00bb fix: remove web sdk team's write permission cc82644c remove unnecessary security team definition 7358d483 Update publish.yml a92e58c3 transform tf to yml
commit de698e3139dae51cd4fb70a8e1f0e45349e951a1
Author: Burak Yigit Kaya <byk@sentry.io>
AuthorDate: Fri Feb 6 23:27:01 2026 +0000
Commit: GitHub <noreply@github.com>
CommitDate: Fri Feb 6 15:27:01 2026 -0800
Update publish repo team permissions (#2394)
Consolidate read and triage permissions into write for engineering team.
Remove redundant team entries that are already children of engineering.
Keep bots-write at triage level.
Co-authored-by: Jeffrey Hung <17494876+Jeffreyhung@users.noreply.github.com>
diff --git a/rbac/env/prod-github/repo/publish.yml b/rbac/env/prod-github/repo/publish.yml
index 5ab8fe83..3d358b40 100644
--- a/rbac/env/prod-github/repo/publish.yml
+++ b/rbac/env/prod-github/repo/publish.yml
@@ -1,15 +1,9 @@
repo_slug: publish
tag: prod
teams:
- read:
+ write:
- engineering
triage:
- - release-approvers
- bots-write
- - team-web-sdk-backend
- - team-javascript-sdks
- - team-mobile
- - gdx
- ...
8ee1105d Add pierre.massat@sentry.io to release approvers (#2335) 91fc042d feat: Add Michi to release approvers (#2019) 8931bc1d Merge pull request #1638 from getsentry/more-approvers 55dff0af feat: Add release approvers 825349c1 Merge pull request #1431 from getsentry/eliatcodecov/add_codecov_to_release_approvers e28a5ea2 Alphabetical Sort of Newly Added Release Approvers 63d013ef Add Trent Schmidt and Tom Hu to release-approvers.tf 5c5e164d Add dev infra team to release approvers (#877) abadfe34 Add Alex to release approvers 3641f7b4 Merge pull request #126 from getsentry/clean-up-github-release-approval-team
# Variables reference Find information for supported variables, naming conventions, limits, and contexts in GitHub Actions workflows. This article lists the supported variables you can use in GitHub Actions workflows, including environment variables, configuration variables, and default variables provided by GitHub. Use this reference to look up variable names, naming conventions, limits, and supported contexts when configuring your workflows. For more information about variables, see [Variables](/en/actions/concepts/workflows-and-actions/variables). ## Default environment variables The default environment variables that GitHub sets are available to every step in a workflow. Because default environment variables are set by GitHub and not defined in a workflow, they are not accessible through the `env` context. However, most of the default variables have a corresponding, and similarly named, context property. ...
<path>/home/byk/Code/getsentry/security-as-code/rbac/env/prod-github/README.md</path> <type>file</type> <content> 1: # Terraforming GitHub Access and Teams 2: 3: - GitHub teams and members are managed in the `rbac/env/prod-github/team/` 4: folder. 5: - GitHub Repo access is managed in the `rbac/env/prod-github/repo` folder. 6: - Team member syncing is handled by `rbac/env/prod-github/team-members-syncing`. 7: 8: ## How-to instructions 9: 10: - How to manage GitHub Teams and Members: 11: https://www.notion.so/sentry/Managing-GitHub-Teams-and-members-ce06bdd4b3464947a242704a4ae258a4 12: - How to manage GitHub Repo Access: 13: https://www.notion.so/sentry/GitHub-Repo-Access-Control-3afa2d9fd93c40b5a8f5bccb85d62926?pvs=4 14: 15: ## For maintainers and technical details 16: 17: Docs: 18: https://www.notion.so/sentry/GitHub-Access-Control-488fe20b98af4064847a97876341af10?pvs=4#bbf36152dfb54fbcb751cccc0b3499d4 (End of file - total 18 lines) </content>
<path>/home/byk/Code/getsentry/publish/auto-approve-repos.txt</path> <type>file</type> <content> 1: getsentry/arroyo 2: getsentry/auto-type-annotate 3: getsentry/devenv 4: getsentry/infra-event-notifier 5: getsentry/jest-sentry-environment 6: getsentry/json-schema-diff 7: getsentry/js-source-scopes 8: getsentry/objectstore/clients 9: getsentry/ophio 10: getsentry/pdb 11: getsentry/pyo3-python-tracing-subscriber 12: getsentry/pytest-sentry 13: getsentry/relay/py 14: getsentry/responses 15: getsentry/rust-proguard 16: getsentry/rust-sourcemap 17: getsentry/rust-usage-accountant 18: getsentry/script-runner 19: getsentry/sentry-api-schema 20: getsentry/sentry-forked-djangorestframework-stubs 21: getsentry/sentry-forked-django-stubs 22: getsentry/sentry-forked-jsonnet 23: getsentry/sentry-infra-tools 24: getsentry/sentry-kafka-management 25: getsentry/sentry-kafka-schemas 26: getsentry/sentry-protos 27: getsentry/sentry-redis-tools 28: getsentry/service-registry 29: getsentry/skrooge 30: ...
{"actor":"getsantry[bot]","created_at":"2026-08-15T17:06:41Z"}
{"permission":"none","role_name":""}
<path>/home/byk/Code/getsentry/security-as-code/rbac/env/prod/team/engineering/release-approvers.tf</path>
<type>file</type>
<content>
1: module "team-group--release-approvers" {
2: source = "../../../../module/group/team"
3:
4: team = "release-approvers"
5: display_name = "Team Release Approvers"
6: description = <<-EOF
7: People who can approve releases in GitHub
8: EOF
9: owners = [
10: "chadwhitacre@sentry.io",
11: ]
12:
13: members = [
14: "alex.jillard@sentry.io",
15: "alexander.weber@sentry.io",
16: "bruno@sentry.io",
17: "dgriesser@sentry.io",
18: "fpacifici@sentry.io",
19: "francesco.novy@sentry.io",
20: "hubert.deng@sentry.io",
21: "indragie.karunaratne@sentry.io",
22: "james.keane@sentry.io",
23: "jan.auer@sentry.io",
24: "matej.minar@sentry.io",
25: "michael.hoffmann@sentry.io",
26: "pierre.massat@sentry.io",
27: "stephanie.anderson@sentry.io",
28: ...
The live GitHub route is feasible and is safer than reading security-as-code from this public workflow. I found one compatibility detail: auto-approved releases are labelled by getsantry[bot], which has no target-repository collaborator role, so the guard must exempt only that existing trusted automation path.
[
{
"content": "Retrieve issue requirements and repository context",
"status": "completed",
"priority": "high"
},
{
"content": "Trace affected release parsing and workflow paths",
"status": "completed",
"priority": "high"
},
{
"content": "Assess feasibility, risks, and test strategy",
"status": "completed",
"priority": "high"
},
{
"content": "Present implementation-ready recommendation",
"status": "completed",
"priority": "medium"
}
]
# About organization teams Teams are groups of organization members that reflect your company or group's structure with cascading access permissions and mentions. ## About teams You can use teams to manage access for people in an organization, and for sending notifications. Organization owners and team maintainers can give teams admin, read, or write access to organization repositories. Organization members can send a notification to an entire team by mentioning the team's name. Teams can only be made up of members of your organization, outside collaborators are unable to be on a team. > \[!TIP] > If you use an enterprise account, you can also create teams at the enterprise level. For more information, see [Teams in an enterprise](/en/enterprise-cloud@latest/admin/concepts/enterprise-fundamentals/teams-in-an-enterprise). Organization members can send a notification to an entire team by requesting a review from that team. ...
The study supports implementation with GitHub as the live authority: accept only write or admin effective access on the target repository, using the existing release-bot installation token. This keeps security-as-code private; it remains the place to grant contractors limited publish triage access, while the workflow stops that access from approving unrelated repositories.
continue
I’ll implement the authorization gate as a fail-closed workflow check, then run the focused and full test suites.
/home/byk/Code/getsentry/publish/.github/workflows/publish.yml /home/byk/Code/getsentry/publish/.github/workflows/ci-poller.yml /home/byk/Code/getsentry/publish/.github/workflows/auto-approve.yml /home/byk/Code/getsentry/publish/.github/workflows/test.yml /home/byk/Code/getsentry/publish/.github/workflows/cocoapods-keepalive.yml
<path>/home/byk/Code/getsentry/publish/.github/workflows/publish.yml</path>
<type>file</type>
<content>
1: name: Publish
2: on:
3: issues:
4: types: [labeled]
5:
6: concurrency:
7: # Use the issue title (e.g. "publish: getsentry/foo@1.2.3") so duplicate
8: # issues for the same repo@version share a concurrency group.
9: group: ${{ github.event.issue.title }}
10: cancel-in-progress: false
11:
12: permissions:
13: contents: read
14: issues: write
15: packages: write
16:
17: jobs:
18: # When accepted is added to a publish issue:
19: # - Add ci-pending (and remove ci-failed if retrying)
20: # - Enable the poller via CI_POLLER_HAS_PENDING=true
21: # - Comment on the issue
22: # - Trigger the poller immediately so we don't wait for the next cron tick
23: # The publish job below requires ci-ready, so it will not fire until the
24: # poller flips ci-pending → ci-ready (which also prevents publishing without
25: # CI verification in the ...
<path>/home/byk/Code/getsentry/publish/src/modules/__tests__/details-from-context.js</path>
<type>file</type>
<content>
1: import { test, expect } from "vitest";
2:
3: const { detailsFromContext } = require("../details-from-context.js");
4:
5: const inputsArgs = {
6: context: {
7: repo: { owner: "getsentry", repo: "publish" },
8: payload: {
9: issue: {
10: number: "223",
11: title: "publish: getsentry/sentry@21.3.1",
12: body: `
13: Requested by: @BYK
14:
15: Merge target: custom-branch
16:
17: Quick links:
18: - [View changes](https://github.com/getsentry/sentry/compare/21.3.0...refs/heads/releases/21.3.1)
19: - [View check runs](https://github.com/getsentry/sentry/commit/7e5ca7ed5581552de066e2a8bc295b8306be38ac/checks/)
20:
21: Assign the **accepted** label to this issue to approve the release. ...
<path>/home/byk/Code/getsentry/publish/src/modules/details-from-context.js</path>
<type>file</type>
<content>
1: /**
2: * Matches the entire "Targets" section of a github publish issue body.
3: */
4: const TARGETS_SECTION_PARSER_REGEX =
5: /^(?!### Targets$\s)(?: *- \[[ xX]\] \S+\s*$(?:\r?\n)?)+/m;
6:
7: /**
8: * Matches all targets of a github publish issue body in a section that was already matched and extracted with `TARGETS_PARSER_REGEX`.
9: * The "id" of the targets is captured within a capture group.
10: */
11: const TARGETS_PARSER_REGEX = /^\s*- \[[ x]\] (\S+)/gim;
12:
13: /**
14: * Matches checked targets of a github publish issue body in a section that was already matched and extracted with `TARGETS_PARSER_REGEX`.
15: * The "id" of the targets is captured within a capture group.
16: */
17: const CHECKED_TARGETS_PARSER_REGEX = /^\s*- \[x\] (\S+)/gim;
18:
19: async function detailsFromContext({ context }) {
20: if (!context || !context.payload || ...
The guard must run as a prerequisite of waiting-for-ci; otherwise an invalid label can race into the poller. I’ll add a small pure authorizer with a failing regression suite first, then wire it into publish.yml with cleanup for explicit denials and hard failure for API errors.
Success. Updated the following files: A src/modules/__tests__/approval-authorizer.js
yarn run v1.22.22 $ vitest run src/modules/__tests__/approval-authorizer.js [1m[46m RUN [49m[22m [36mv4.0.17 [39m[90m/home/byk/Code/getsentry/publish[39m [31m❯[39m src/modules/__tests__/approval-authorizer.js [2m([22m[2m0 test[22m[2m)[22m [2m Test Files [22m [1m[31m1 failed[39m[22m[90m (1)[39m [2m Tests [22m [2mno tests[22m [2m Start at [22m 14:37:11 [2m Duration [22m 213ms[2m (transform 24ms, setup 0ms, import 0ms, tests 0ms, environment 0ms)[22m info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command. [31m⎯⎯⎯⎯⎯⎯[39m[1m[41m Failed Suites 1 [49m[22m[31m⎯⎯⎯⎯⎯⎯⎯[39m [41m[1m FAIL [22m[49m src/modules/__tests__/approval-authorizer.js[2m [ src/modules/__tests__/approval-authorizer.js ][22m [31m[1mError[22m: Cannot find module '../approval-authorizer.js' Require stack: - /home/byk/Code/getsentry/publish/src/modules/__tests__/approval-authorizer.js[39m [36m [2m❯[22m ...
<path>/home/byk/Code/getsentry/publish/.github/workflows/ci-poller.yml</path> <type>file</type> <content> 1: name: CI Status Poller 2: 3: on: 4: schedule: 5: - cron: "*/5 * * * *" 6: workflow_dispatch: 7: inputs: 8: attempt: 9: description: Self-dispatch attempt counter (internal use) 10: required: false 11: default: "0" 12: 13: permissions: 14: contents: read 15: issues: read 16: 17: jobs: 18: check-ci: 19: runs-on: ubuntu-latest 20: environment: production 21: # Skip entirely (no runner provisioned) when there's nothing to check. 22: # Set to "true" by ci-pending.yml, reset to "false" here when done. 23: # Always allow workflow_dispatch for manual recovery. 24: if: vars.CI_POLLER_HAS_PENDING == 'true' || github.event_name == 'workflow_dispatch' 25: concurrency: 26: group: ci-status-poller 27: cancel-in-progress: false 28: steps: 29: # sentry-internal-app token for label changes on ...
<path>/home/byk/Code/getsentry/publish/.github/workflows/publish.yml</path>
<type>file</type>
<content>
1: name: Publish
2: on:
3: issues:
4: types: [labeled]
5:
6: concurrency:
7: # Use the issue title (e.g. "publish: getsentry/foo@1.2.3") so duplicate
8: # issues for the same repo@version share a concurrency group.
9: group: ${{ github.event.issue.title }}
10: cancel-in-progress: false
11:
12: permissions:
13: contents: read
14: issues: write
15: packages: write
16:
17: jobs:
18: # When accepted is added to a publish issue:
19: # - Add ci-pending (and remove ci-failed if retrying)
20: # - Enable the poller via CI_POLLER_HAS_PENDING=true
21: # - Comment on the issue
22: # - Trigger the poller immediately so we don't wait for the next cron tick
23: # The publish job below requires ci-ready, so it will not fire until the
24: # poller flips ci-pending → ci-ready (which also prevents publishing without
25: # CI verification in the ...
Success. Updated the following files: A src/modules/approval-authorizer.js M .github/workflows/publish.yml
The initial wiring has a sequencing defect: its rejection step refers to an app token before that token exists. I’ll correct the gate, validate the workflow structure, and run the focused tests and lint.
[
{
"content": "Complete approval gate implementation",
"status": "in_progress",
"priority": "high"
},
{
"content": "Add authorization regression coverage",
"status": "pending",
"priority": "high"
},
{
"content": "Validate workflow and test suite",
"status": "pending",
"priority": "high"
}
]
Success. Updated the following files: M .github/workflows/publish.yml
continue
I’ll finish the authorization path and verify it end to end. The gate must deny unauthorized label events before CI polling, remove the unsafe label, and fail closed if the GitHub permission lookup fails.
No files found
<path>/home/byk/Code/getsentry/publish/src/modules/approval-authorizer.js</path>
<type>file</type>
<content>
1: const ALLOWED_ROLE_NAMES = new Set(["write", "maintain", "admin"]);
2: const TRUSTED_BOTS = new Set(["getsantry[bot]", "sentry-release-bot[bot]"]);
3: const PUBLISH_TITLE_REGEX = /^publish: (?<owner>[^/]+)\/(?<repository>[^/@]+)(?:\/[^@]+)?@[^@]+$/;
4:
5: async function authorizeApproval({ actor, issueTitle, getPermission }) {
6: const title = issueTitle.match(PUBLISH_TITLE_REGEX);
7:
8: if (!title || title.groups.owner !== "getsentry") {
9: return { authorized: false, repository: null };
10: }
11:
12: const repository = `${title.groups.owner}/${title.groups.repository}`;
13:
14: if (TRUSTED_BOTS.has(actor)) {
15: return { authorized: true, repository };
16: }
17:
18: const { role_name } = await getPermission({
19: owner: title.groups.owner,
20: repository: title.groups.repository,
21: username: actor,
22: });
23:
24: return { ...
<path>/home/byk/Code/getsentry/publish/src/modules/__tests__/approval-authorizer.js</path>
<type>file</type>
<content>
1: import { describe, expect, test, vi } from "vitest";
2:
3: const {
4: authorizeApproval,
5: } = require("../approval-authorizer.js");
6:
7: const issueTitle = "publish: getsentry/sentry-javascript@10.0.0";
8:
9: describe("authorizeApproval", () => {
10: test("allows the trusted auto-approval bot without a target repository lookup", async () => {
11: const getPermission = vi.fn();
12:
13: await expect(
14: authorizeApproval({
15: actor: "getsantry[bot]",
16: issueTitle,
17: getPermission,
18: })
19: ).resolves.toEqual({ authorized: true, repository: "getsentry/sentry-javascript" });
20:
21: expect(getPermission).not.toHaveBeenCalled();
22: });
23:
24: test.each(["write", "maintain", "admin"])(
25: "allows a target repository %s collaborator",
26: async (roleName) => {
27: const ...
<path>/home/byk/Code/getsentry/publish/.github/workflows/publish.yml</path> <type>file</type> <content> 17: jobs: 18: # When accepted is added to a publish issue: 19: # - Add ci-pending (and remove ci-failed if retrying) 20: # - Enable the poller via CI_POLLER_HAS_PENDING=true 21: # - Comment on the issue 22: # - Trigger the poller immediately so we don't wait for the next cron tick 23: # The publish job below requires ci-ready, so it will not fire until the 24: # poller flips ci-pending → ci-ready (which also prevents publishing without 25: # CI verification in the auto-approve race). 26: waiting-for-ci: 27: runs-on: ubuntu-latest 28: name: Waiting for CI 29: environment: production 30: if: >- 31: github.event.label.name == 'accepted' 32: && github.event.issue.state == 'open' 33: && startsWith(github.event.issue.title, 'publish: ') 34: steps: 35: # The release bot sees all target repositories. ...
M .github/workflows/publish.yml
?? src/modules/__tests__/approval-authorizer.js
?? src/modules/approval-authorizer.js
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index cff6bc3..0f76609 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -32,6 +32,40 @@ jobs:
&& github.event.issue.state == 'open'
&& startsWith(github.event.issue.title, 'publish: ')
steps:
+ # The release bot sees all target repositories. This checks the actor's
+ # current effective target-repository role without exposing RBAC data.
+ - name: Get release bot auth token
+ id: release-token
+ uses: actions/create-github-app-token@v3
+ with:
+ client-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }}
+ private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }}
+ owner: getsentry
+
+ - name: Authorize approval
+ id: authorization
+ env:
+ GH_TOKEN: ${{ ...
The current gate would mis-handle unqualified legacy titles, and the approval event for auto-approval is authored by the app token rather than the issue-opening bot. I’m checking the live event identity, then will consolidate the tested authorizer into an executable workflow script.
...output truncated... Full output saved to: /home/byk/.local/share/opencode/tool-output/tool_043c43ed70019QnMOsQDWwRD3b 2026-05-05T22:06:20Z getsantry[bot] 8090 2026-05-05T21:00:44Z getsantry[bot] 8088 2026-05-05T19:15:21Z getsantry[bot] 8086 2026-05-05T18:40:33Z getsantry[bot] 8085 2026-05-05T17:53:20Z getsantry[bot] 8084 2026-05-05T17:14:10Z getsantry[bot] 8083 2026-05-05T17:01:25Z Flash0ver 8081 2026-05-05T15:59:33Z JPeer264 8082 2026-05-05T14:27:56Z cleptric 8078 2026-05-05T14:12:13Z BYK 8080 2026-05-05T13:56:21Z getsantry[bot] 8079 2026-05-05T13:40:05Z cleptric 8076 2026-05-05T13:40:03Z cleptric 8075 2026-05-05T13:40:02Z cleptric 8074 2026-05-05T13:40:01Z cleptric 8073 2026-05-05T13:40:00Z cleptric 8072 2026-05-05T13:39:57Z cleptric 8071 2026-05-05T13:39:42Z cleptric 8077 2026-05-05T12:16:07Z Flash0ver 8066 2026-05-05T12:11:29Z Flash0ver 8066 2026-05-05T12:07:19Z Flash0ver 8066 2026-05-05T10:47:30Z Flash0ver 8066 2026-05-05T09:32:15Z mujacica 8062 2026-05-05T09:24:50Z getsantry...
The live issue events confirm that getsantry[bot] is the actor for auto-approval labels. I’ll preserve that narrow exception, route the workflow through the tested authorizer, and keep API failures fail-closed.
Success. Updated the following files: A src/publish/authorize-approval.js M .github/workflows/publish.yml M src/modules/__tests__/approval-authorizer.js
The shared authorizer now drives the workflow, so behavior is testable outside GitHub Actions. I’m narrowing automation bypasses to the observed auto-approver and adding coverage for the workflow entry point.
<task id="ses_fbc38c6f8ffeXX5jETbeKd7cSO" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
Success. Updated the following files: M src/modules/approval-authorizer.js M src/modules/__tests__/approval-authorizer.js
Success. Updated the following files: A src/publish/__tests__/authorize-approval.js
The entry-point test covers the actual API request and fail-closed behavior. I’m running the focused suites next; lint’s current failure is an unrelated pre-existing YAML issue in cocoapods-keepalive.yml.
yarn run v1.22.22 $ vitest run src/modules/__tests__/approval-authorizer.js src/publish/__tests__/authorize-approval.js [1m[46m RUN [49m[22m [36mv4.0.17 [39m[90m/home/byk/Code/getsentry/publish[39m [32m✓[39m src/modules/__tests__/approval-authorizer.js [2m([22m[2m12 tests[22m[2m)[22m[32m 14[2mms[22m[39m [32m✓[39m src/publish/__tests__/authorize-approval.js [2m([22m[2m2 tests[22m[2m)[22m[32m 25[2mms[22m[39m [2m Test Files [22m [1m[32m2 passed[39m[22m[90m (2)[39m [2m Tests [22m [1m[32m14 passed[39m[22m[90m (14)[39m [2m Start at [22m 15:12:54 [2m Duration [22m 254ms[2m (transform 65ms, setup 0ms, import 91ms, tests 39ms, environment 0ms)[22m Done in 0.71s. yarn run v1.22.22 $ prettier --write src --check .github/workflows/publish.yml src/modules/approval-authorizer.js src/modules/__tests__/approval-authorizer.js src/publish/authorize-approval.js src/publish/__tests__/authorize-approval.js Checking formatting... ...
I found one fail-closed gap: a release-bot API failure currently stops the job but leaves accepted attached. I’ll move the internal token ahead of authorization and make cleanup run after both denials and lookup failures.
<path>/home/byk/Code/getsentry/publish/src/publish/inputs.js</path>
<type>file</type>
<content>
1: const github = require("@actions/github");
2: const core = require("@actions/core");
3: const { detailsFromContext } = require("../modules/details-from-context");
4:
5: async function inputs() {
6: const result = await detailsFromContext({
7: context: github.context,
8: });
9: core.setOutput("result", result);
10: }
11:
12: inputs();
(End of file - total 12 lines)
</content>
Success. Updated the following files: M src/publish/authorize-approval.js M .github/workflows/publish.yml M src/publish/__tests__/authorize-approval.js
The workflow now clears accepted after any authorization failure, including GitHub API errors, before stopping. I’ll rerun tests and targeted formatting and YAML validation.
yarn run v1.22.22 $ vitest run src/modules/__tests__/approval-authorizer.js src/publish/__tests__/authorize-approval.js [1m[46m RUN [49m[22m [36mv4.0.17 [39m[90m/home/byk/Code/getsentry/publish[39m [32m✓[39m src/publish/__tests__/authorize-approval.js [2m([22m[2m2 tests[22m[2m)[22m[32m 11[2mms[22m[39m [32m✓[39m src/modules/__tests__/approval-authorizer.js [2m([22m[2m12 tests[22m[2m)[22m[32m 14[2mms[22m[39m [2m Test Files [22m [1m[32m2 passed[39m[22m[90m (2)[39m [2m Tests [22m [1m[32m14 passed[39m[22m[90m (14)[39m [2m Start at [22m 15:14:26 [2m Duration [22m 287ms[2m (transform 42ms, setup 0ms, import 79ms, tests 26ms, environment 0ms)[22m Done in 0.74s. Checking formatting... All matched files use Prettier code style!
The focused checks pass. I found one remaining recovery edge case: if the release-bot token cannot be created, GitHub skips the authorizer before it can remove accepted; I’ll let that step fail softly so the existing internal token can clean up safely.
Success. Updated the following files: M .github/workflows/publish.yml
I’ve covered the token-creation failure path: authorization now fails safely, and the internal app removes accepted. I’m validating the complete test suite and linting the touched code while the independent review completes.
[
{
"content": "Complete approval gate implementation",
"status": "completed",
"priority": "high"
},
{
"content": "Add authorization regression coverage",
"status": "completed",
"priority": "high"
},
{
"content": "Validate workflow and test suite",
"status": "in_progress",
"priority": "high"
},
{
"content": "Review final authorization changes",
"status": "pending",
"priority": "high"
}
]
yarn run v1.22.22 $ vitest run [1m[46m RUN [49m[22m [36mv4.0.17 [39m[90m/home/byk/Code/getsentry/publish[39m [32m✓[39m src/modules/__tests__/post-workflow-details.js [2m([22m[2m1 test[22m[2m)[22m[32m 19[2mms[22m[39m [32m✓[39m src/modules/__tests__/approval-authorizer.js [2m([22m[2m12 tests[22m[2m)[22m[32m 33[2mms[22m[39m [32m✓[39m src/publish/__tests__/authorize-approval.js [2m([22m[2m2 tests[22m[2m)[22m[32m 23[2mms[22m[39m [32m✓[39m src/modules/__tests__/update-issue.js [2m([22m[2m5 tests[22m[2m)[22m[32m 14[2mms[22m[39m [32m✓[39m src/modules/__tests__/process-end-state.js [2m([22m[2m4 tests[22m[2m)[22m[32m 43[2mms[22m[39m [32m✓[39m src/modules/__tests__/details-from-context.js [2m([22m[2m4 tests[22m[2m)[22m[32m 7[2mms[22m[39m [32m✓[39m src/libs/__tests__/github.js [2m([22m[2m2 tests[22m[2m)[22m[32m 6[2mms[22m[39m [2m Test Files [22m [1m[32m7 passed[39m[22m[90m (7)[39m [2m ...
The complete suite and targeted lint/format checks pass. I found an authorization compatibility gap before review: Publish also accepts legacy unqualified titles such as publish: sentry@…; the gate must resolve those to getsentry/sentry rather than reject them.
<path>/home/byk/Code/getsentry/publish/src/modules/approval-authorizer.js</path>
<type>file</type>
<content>
1: const ALLOWED_ROLE_NAMES = new Set(["write", "maintain", "admin"]);
2: const AUTO_APPROVER = "getsantry[bot]";
3: const PUBLISH_TITLE_REGEX =
4: /^publish: (?<owner>[^/]+)\/(?<repository>[^/@]+)(?:\/[^@]+)?@[^@]+$/;
5:
6: async function authorizeApproval({ actor, issueTitle, getPermission }) {
7: const title = issueTitle.match(PUBLISH_TITLE_REGEX);
8:
9: if (!title || title.groups.owner !== "getsentry") {
10: return { authorized: false, repository: null };
11: }
12:
13: const repository = `${title.groups.owner}/${title.groups.repository}`;
14:
15: if (actor === AUTO_APPROVER) {
16: return { authorized: true, repository };
17: }
18:
19: const { role_name } = await getPermission({
20: owner: title.groups.owner,
21: repository: title.groups.repository,
22: username: actor,
23: });
24:
25: return { authorized: ...
I’ll make unqualified titles map explicitly to getsentry, preserve path handling, and add regression coverage for both title forms.
Success. Updated the following files: M src/modules/approval-authorizer.js M src/modules/__tests__/approval-authorizer.js
yarn run v1.22.22
$ vitest run src/modules/__tests__/approval-authorizer.js src/publish/__tests__/authorize-approval.js
[1m[46m RUN [49m[22m [36mv4.0.17 [39m[90m/home/byk/Code/getsentry/publish[39m
[31m❯[39m src/modules/__tests__/approval-authorizer.js [2m([22m[2m13 tests[22m[2m | [22m[31m1 failed[39m[2m)[22m[32m 26[2mms[22m[39m
[32m✓[39m allows the trusted auto-approval bot without a target repository lookup[32m 5[2mms[22m[39m
[32m✓[39m allows a target repository write collaborator[32m 4[2mms[22m[39m
[32m✓[39m allows a target repository maintain collaborator[32m 1[2mms[22m[39m
[32m✓[39m allows a target repository admin collaborator[32m 2[2mms[22m[39m
[31m [31m×[31m authorizes an unqualified title against the getsentry repository[39m[32m 7[2mms[22m[39m
[32m✓[39m rejects a none target repository collaborator[32m 0[2mms[22m[39m
[32m✓[39m rejects a read target repository collaborator[32m ...
The failing regression exposed an ambiguity in the first parser: it interpreted the repository name as an owner when a release includes a subdirectory. I’ll align authorization with Publish’s existing title grammar, which treats both qualified and unqualified titles as getsentry releases.
Success. Updated the following files: M src/modules/approval-authorizer.js M src/modules/__tests__/approval-authorizer.js
yarn run v1.22.22 $ vitest run src/modules/__tests__/approval-authorizer.js src/publish/__tests__/authorize-approval.js [1m[46m RUN [49m[22m [36mv4.0.17 [39m[90m/home/byk/Code/getsentry/publish[39m [32m✓[39m src/modules/__tests__/approval-authorizer.js [2m([22m[2m13 tests[22m[2m)[22m[32m 14[2mms[22m[39m [32m✓[39m src/publish/__tests__/authorize-approval.js [2m([22m[2m2 tests[22m[2m)[22m[32m 11[2mms[22m[39m [2m Test Files [22m [1m[32m2 passed[39m[22m[90m (2)[39m [2m Tests [22m [1m[32m15 passed[39m[22m[90m (15)[39m [2m Start at [22m 15:24:23 [2m Duration [22m 295ms[2m (transform 56ms, setup 0ms, import 97ms, tests 26ms, environment 0ms)[22m Done in 0.79s. Checking formatting... All matched files use Prettier code style!
The parser now shares Publish’s getsentry title convention and the fail-first regression passes. I’ll rerun full validation and inspect the final diff; the independent review remains a required merge gate.
No files found
yarn run v1.22.22 $ vitest run [1m[46m RUN [49m[22m [36mv4.0.17 [39m[90m/home/byk/Code/getsentry/publish[39m [32m✓[39m src/modules/__tests__/post-workflow-details.js [2m([22m[2m1 test[22m[2m)[22m[32m 24[2mms[22m[39m [32m✓[39m src/modules/__tests__/approval-authorizer.js [2m([22m[2m13 tests[22m[2m)[22m[32m 31[2mms[22m[39m [32m✓[39m src/publish/__tests__/authorize-approval.js [2m([22m[2m2 tests[22m[2m)[22m[32m 17[2mms[22m[39m [32m✓[39m src/modules/__tests__/update-issue.js [2m([22m[2m5 tests[22m[2m)[22m[32m 23[2mms[22m[39m [32m✓[39m src/modules/__tests__/details-from-context.js [2m([22m[2m4 tests[22m[2m)[22m[32m 13[2mms[22m[39m [32m✓[39m src/libs/__tests__/github.js [2m([22m[2m2 tests[22m[2m)[22m[32m 13[2mms[22m[39m [32m✓[39m src/modules/__tests__/process-end-state.js [2m([22m[2m4 tests[22m[2m)[22m[32m 60[2mms[22m[39m [2m Test Files [22m [1m[32m7 passed[39m[22m[90m (7)[39m [2m ...
yarn test passes with 31 tests. Full lint remains blocked only by the pre-existing cocoapods-keepalive.yml scalar error, so I’ll finish the independent targeted checks and inspect the authorization path before the reviewer reports.
Checking formatting...
All matched files use Prettier code style!
M .github/workflows/publish.yml
M src/libs/__tests__/github.js
M src/libs/github.js
M src/modules/post-workflow-details.js
M src/modules/process-end-state.js
M src/modules/update-issue.js
M src/publish/inputs.js
M src/publish/post-result.js
M src/publish/post-workflow-details.js
M src/publish/update-issue.js
?? src/modules/__tests__/approval-authorizer.js
?? src/modules/approval-authorizer.js
?? src/publish/__tests__/
?? src/publish/authorize-approval.js
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index cff6bc3..081a17b 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -32,6 +32,11 @@ jobs:
&& github.event.issue.state == 'open'
&& startsWith(github.event.issue.title, 'publish: ')
steps:
+ - name: Get publish code
+ uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
- name: Get auth ...
<path>/home/byk/Code/getsentry/publish/.github/workflows/publish.yml</path>
<type>file</type>
<content>
1: name: Publish
2: on:
3: issues:
4: types: [labeled]
5:
6: concurrency:
7: # Use the issue title (e.g. "publish: getsentry/foo@1.2.3") so duplicate
8: # issues for the same repo@version share a concurrency group.
9: group: ${{ github.event.issue.title }}
10: cancel-in-progress: false
11:
12: permissions:
13: contents: read
14: issues: write
15: packages: write
16:
17: jobs:
18: # When accepted is added to a publish issue:
19: # - Add ci-pending (and remove ci-failed if retrying)
20: # - Enable the poller via CI_POLLER_HAS_PENDING=true
21: # - Comment on the issue
22: # - Trigger the poller immediately so we don't wait for the next cron tick
23: # The publish job below requires ci-ready, so it will not fire until the
24: # poller flips ci-pending → ci-ready (which also prevents publishing without
25: # CI verification in the ...
<path>/home/byk/Code/getsentry/publish/src/publish/authorize-approval.js</path>
<type>file</type>
<content>
1: const fs = require("fs");
2: const { authorizeApproval } = require("../modules/approval-authorizer.js");
3:
4: async function getPermission({ owner, repository, username }) {
5: const response = await fetch(
6: `https://api.github.com/repos/${encodeURIComponent(
7: owner
8: )}/${encodeURIComponent(repository)}/collaborators/${encodeURIComponent(
9: username
10: )}/permission`,
11: {
12: headers: {
13: Accept: "application/vnd.github+json",
14: Authorization: `Bearer ${process.env.APPROVAL_TOKEN}`,
15: "X-GitHub-Api-Version": "2026-03-10",
16: },
17: }
18: );
19:
20: if (!response.ok) {
21: throw new Error(
22: `Could not retrieve ${username}'s permission for ${owner}/${repository}: GitHub returned ${response.status}`
23: );
24: }
25:
26: return response.json();
27: }
28:
29: async ...
The full suite passes; full lint remains blocked by the unrelated pre-existing YAML error in cocoapods-keepalive.yml. Targeted lint, Prettier, and whitespace checks pass. I’m awaiting the independent review before closing this implementation.
Implementation and validation are complete pending the independent review.
HIGH — src/modules/approval-authorizer.js:14-16: getsantry[bot] bypasses all target-repository authorization. The acceptance gate does not verify that the label came from the allowlisted auto-approval path, so any accepted label event by this bot authorizes any getsentry release. This also violates the second-person requirement.
MEDIUM — src/modules/approval-authorizer.js:3,8: Authorization rejects titles without an explicit getsentry/ owner, while the existing publish parser accepts them (src/modules/details-from-context.js:24-25). Valid requests such as publish: sentry@21.3.1 will have accepted removed. No test covers this supported title form.
DO-NOT-MERGE </task_result> </task>
The review confirms one real hardening gap: the automation exception should be limited to the existing auto-approve-repos.txt allowlist. The self-approval finding does not match issue #9355, which authorizes maintainers by their target-repository access; I will preserve that policy. The reported unqualified-title issue is already fixed in the current tree.
<path>/home/byk/Code/getsentry/publish/src/modules/__tests__/approval-authorizer.js</path>
<type>file</type>
<content>
1: import { describe, expect, test, vi } from "vitest";
2:
3: const { authorizeApproval } = require("../approval-authorizer.js");
4:
5: const issueTitle = "publish: getsentry/sentry-javascript@10.0.0";
6:
7: describe("authorizeApproval", () => {
8: test("allows the trusted auto-approval bot without a target repository lookup", async () => {
9: const getPermission = vi.fn();
10:
11: await expect(
12: authorizeApproval({
13: actor: "getsantry[bot]",
14: issueTitle,
15: getPermission,
16: })
17: ).resolves.toEqual({
18: authorized: true,
19: repository: "getsentry/sentry-javascript",
20: });
21:
22: expect(getPermission).not.toHaveBeenCalled();
23: });
24:
25: test.each(["write", "maintain", "admin"])(
26: "allows a target repository %s collaborator",
27: async (roleName) => {
28: ...
<path>/home/byk/Code/getsentry/publish/src/publish/__tests__/authorize-approval.js</path>
<type>file</type>
<content>
1: import { afterEach, describe, expect, test, vi } from "vitest";
2: import fs from "fs";
3: const { main } = require("../authorize-approval.js");
4:
5: const originalEnvironment = { ...process.env };
6:
7: afterEach(() => {
8: process.env = { ...originalEnvironment };
9: vi.restoreAllMocks();
10: vi.resetModules();
11: });
12:
13: async function runAuthorization({ actor, issueTitle, response }) {
14: process.env.GITHUB_OUTPUT = "/tmp/github-output";
15: process.env.APPROVAL_TOKEN = "release-bot-token";
16: process.env.APPROVAL_ACTOR = actor;
17: process.env.APPROVAL_ISSUE_TITLE = issueTitle;
18:
19: const appendFileSync = vi
20: .spyOn(fs, "appendFileSync")
21: .mockImplementation(() => {});
22: global.fetch = vi.fn().mockResolvedValue(response);
23:
24: await main();
25: await vi.waitFor(() => ...
I’ll restrict the bot exception to the exact release paths in auto-approve-repos.txt. That preserves non-SDK auto approval without letting an automation identity bypass target-repository checks for arbitrary releases.
<path>/home/byk/Code/getsentry/publish/.github/workflows/auto-approve.yml</path>
<type>file</type>
<content>
1: name: auto-approve non-sdks
2: on:
3: issues:
4: types: [opened]
5:
6: permissions:
7: contents: read
8:
9: jobs:
10: auto-approve:
11: runs-on: ubuntu-latest
12: if: |
13: (github.actor == 'sentry-release-bot[bot]' || github.actor == 'getsantry[bot]') &&
14: startsWith(github.event.issue.title, 'publish: ')
15: steps:
16: - uses: actions/checkout@v4
17: with:
18: sparse-checkout: auto-approve-repos.txt
19: sparse-checkout-cone-mode: false
20: - name: Get auth token
21: id: token
22: uses: actions/create-github-app-token@v3
23: with:
24: client-id: ${{ vars.SENTRY_INTERNAL_APP_ID }}
25: private-key: ${{ secrets.SENTRY_INTERNAL_APP_PRIVATE_KEY }}
26: - name: Auto-approve if repo is in the list
27: env:
28: GH_TOKEN: ${{ ...
Success. Updated the following files: M src/modules/approval-authorizer.js M src/publish/authorize-approval.js M src/modules/__tests__/approval-authorizer.js
The first restriction edit needs one correction: the parser must retain the optional release path and accept the allowlist as an explicit input. I’ll fix that, then cover the file-backed allowlist in the entry-point tests.
<path>/home/byk/Code/getsentry/publish/src/modules/approval-authorizer.js</path>
<type>file</type>
<content>
1: const ALLOWED_ROLE_NAMES = new Set(["write", "maintain", "admin"]);
2: const AUTO_APPROVER = "getsantry[bot]";
3: const PUBLISH_TITLE_REGEX =
4: /^publish: (?:getsentry\/)?(?<repository>[^/@]+)(?:\/[^@]+)?@[^@]+$/;
5:
6: async function authorizeApproval({ actor, issueTitle, getPermission }) {
7: const title = issueTitle.match(PUBLISH_TITLE_REGEX);
8:
9: if (!title) {
10: return { authorized: false, repository: null };
11: }
12:
13: const repository = `getsentry/${title.groups.repository}`;
14: const releasePath = `${repository}${title.groups.path || ""}`;
15:
16: if (actor === AUTO_APPROVER && autoApprovedRepositories.has(releasePath)) {
17: return { authorized: true, repository };
18: }
19:
20: const { role_name } = await getPermission({
21: owner: "getsentry",
22: repository: title.groups.repository,
23: username: actor,
24: ...
Success. Updated the following files: M src/modules/approval-authorizer.js M src/publish/__tests__/authorize-approval.js
The allowlist now preserves monorepo paths and defaults to no bot bypass. I’ll complete its regression coverage, then rerun the final suite and fresh correctness/security reviews against this tree.
Success. Updated the following files: M src/modules/__tests__/approval-authorizer.js
yarn run v1.22.22 $ vitest run [1m[46m RUN [49m[22m [36mv4.0.17 [39m[90m/home/byk/Code/getsentry/publish[39m [32m✓[39m src/modules/__tests__/approval-authorizer.js [2m([22m[2m14 tests[22m[2m)[22m[32m 16[2mms[22m[39m [32m✓[39m src/modules/__tests__/post-workflow-details.js [2m([22m[2m1 test[22m[2m)[22m[32m 14[2mms[22m[39m [32m✓[39m src/publish/__tests__/authorize-approval.js [2m([22m[2m3 tests[22m[2m)[22m[32m 14[2mms[22m[39m [32m✓[39m src/modules/__tests__/update-issue.js [2m([22m[2m5 tests[22m[2m)[22m[32m 28[2mms[22m[39m [32m✓[39m src/modules/__tests__/process-end-state.js [2m([22m[2m4 tests[22m[2m)[22m[32m 40[2mms[22m[39m [32m✓[39m src/libs/__tests__/github.js [2m([22m[2m2 tests[22m[2m)[22m[32m 5[2mms[22m[39m [32m✓[39m src/modules/__tests__/details-from-context.js [2m([22m[2m4 tests[22m[2m)[22m[32m 7[2mms[22m[39m [2m Test Files [22m [1m[32m7 passed[39m[22m[90m (7)[39m [2m ...
All 33 tests pass. Only the new entry-point test needs formatting; I’ll format that touched file and then run the final checks plus separate correctness and security reviews.
src/publish/__tests__/authorize-approval.js 95ms Checking formatting... All matched files use Prettier code style!
<task id="ses_fbc205116ffesmEG8SWfAbIqaj" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
<task id="ses_fbc2027dbfferQZUN0ETLQ4agz" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
Two independent final reviews are now running against the hardened tree: one for workflow correctness and one for security. Validation is green except for the repository-wide, pre-existing YAML lint violation.
Final reviews are running. Current validation: 33 tests pass; targeted lint, formatting, and whitespace checks pass. Repository-wide lint remains blocked by the pre-existing .github/workflows/cocoapods-keepalive.yml:1 YAML scalar violation.
MUST-FIX — Critical — .github/workflows/publish.yml:64, .github/workflows/ci-poller.yml:92 — Authorization uses the title snapshot from the accepted event, while the poller later reads the current issue title. An approver for repo A can add accepted, then change the title to repo B before polling. Repo B releases without an access check for it.
MUST-FIX — High — .github/workflows/publish.yml:63, .github/workflows/auto-approve.yml:28 — Auto-approve adds accepted with the internal-app token, so the labeled event actor is that app, not getsantry[bot]. The gate rejects valid allowlisted bot approvals or authorizes them based on the internal app’s target-repo role.
MUST-FIX — High — src/modules/approval-authorizer.js:21-31 — A non-allowlisted getsantry[bot] falls through to the permission lookup and is authorized when GitHub returns write, maintain, or admin. The bot must be rejected unless its release path exactly matches the allowlist.
MUST-FIX — High — src/modules/approval-authorizer.js:4, src/modules/details-from-context.js:25 — The authorization parser accepts titles Publish cannot parse. For example, the allowlisted publish: getsentry/relay/py@bad version authorizes, but the Publish parser throws. The regex must share Publish’s title grammar and fail closed for unsupported titles.
MUST-FIX — Medium — .github/workflows/publish.yml:35-45, .github/workflows/publish.yml:67 — Checkout or the internal-app token can fail before the only rejection step. GitHub skips that step after an earlier failure, leaving accepted attached. This violates the required cleanup on failed checks.
CONCERN — src/modules/__tests__/approval-authorizer.js:26-45 — Tests cover a non-allowlisted bot only with role_name: "none"; they miss the bypass with write. ...
The correctness review found five must-fix paths, including a time-of-check/time-of-use title mutation, the app-token event actor, overly permissive bot fallback, parser drift, and cleanup gaps before authorization starts. I’ll reconcile these with the security review, then repair the gate and add adversarial regressions.
Correctness review found must-fix authorization gaps. The security review is still running; I’ll apply a consolidated fix after it returns.
MUST-FIX — Critical — Title TOCTOU permits authorization for repository A and publishing repository B. Authorization consumes the accepted-event title, but only persists authorized; it never binds that title or repository to later stages. The poller and publish job consume the mutable current title. An actor authorized for A can retitle their issue to B before CI completes.
.github/workflows/publish.yml:62-65,90-98,174-176; .github/workflows/ci-poller.yml:80,90-100; src/publish/authorize-approval.js:46-53
MUST-FIX — High — Failures before authorization leave accepted attached. A failed checkout or internal-app token creation skips all later steps under GitHub Actions’ implicit success() condition. The rejection step also depends on the failed app token, so it cannot remove the label. This violates the required fail-closed cleanup behavior.
.github/workflows/publish.yml:35-45,67-80
MUST-FIX — Medium — The auto-approval bypass cannot reliably identify getsantry[bot]. auto-approve.yml adds the label with the internal-app token, so the subsequent labeled event identifies that app, not the originating bot. It also accepts sentry-release-bot[bot]. The authorization code then either rejects legitimate allowlisted automation or authorizes based on the app’s repository role rather than exact trusted provenance.
.github/workflows/auto-approve.yml:13,20-34; .github/workflows/publish.yml:62-65; src/modules/approval-authorizer.js:2,17-21
CONCERN — Low — The authorization title grammar is broader than the publish parser. It can approve titles that publishing later rejects, creating divergent behavior and future bypass risk if either parser changes. Use one shared, exact parser.
src/modules/approval-authorizer.js:3-4; ...
Both reviews agree on the attack paths. I’ll bind authorization to an internal-app attestation of the exact title, verify it immediately before publishing, distinguish auto approval through the immutable issue creator plus the internal app identity, and make cleanup run after every pre-authorization failure.
[
{
"content": "Harden approval authorization workflow",
"status": "in_progress",
"priority": "high"
},
{
"content": "Add adversarial authorization regressions",
"status": "pending",
"priority": "high"
},
{
"content": "Run validation and final reviews",
"status": "pending",
"priority": "high"
}
]
<path>/home/byk/Code/getsentry/publish/.github/workflows/ci-poller.yml</path> <type>file</type> <content> 1: name: CI Status Poller 2: 3: on: 4: schedule: 5: - cron: "*/5 * * * *" 6: workflow_dispatch: 7: inputs: 8: attempt: 9: description: Self-dispatch attempt counter (internal use) 10: required: false 11: default: "0" 12: 13: permissions: 14: contents: read 15: issues: read 16: 17: jobs: 18: check-ci: 19: runs-on: ubuntu-latest 20: environment: production 21: # Skip entirely (no runner provisioned) when there's nothing to check. 22: # Set to "true" by ci-pending.yml, reset to "false" here when done. 23: # Always allow workflow_dispatch for manual recovery. 24: if: vars.CI_POLLER_HAS_PENDING == 'true' || github.event_name == 'workflow_dispatch' 25: concurrency: 26: group: ci-status-poller 27: cancel-in-progress: false 28: steps: 29: # sentry-internal-app token for label changes on ...
{"actor":"joshuarli","created_at":"2026-08-25T20:47:45Z"}
<path>/home/byk/Code/getsentry/publish/src/modules/details-from-context.js</path>
<type>file</type>
<content>
1: /**
2: * Matches the entire "Targets" section of a github publish issue body.
3: */
4: const TARGETS_SECTION_PARSER_REGEX =
5: /^(?!### Targets$\s)(?: *- \[[ xX]\] \S+\s*$(?:\r?\n)?)+/m;
6:
7: /**
8: * Matches all targets of a github publish issue body in a section that was already matched and extracted with `TARGETS_PARSER_REGEX`.
9: * The "id" of the targets is captured within a capture group.
10: */
11: const TARGETS_PARSER_REGEX = /^\s*- \[[ x]\] (\S+)/gim;
12:
13: /**
14: * Matches checked targets of a github publish issue body in a section that was already matched and extracted with `TARGETS_PARSER_REGEX`.
15: * The "id" of the targets is captured within a capture group.
16: */
17: const CHECKED_TARGETS_PARSER_REGEX = /^\s*- \[x\] (\S+)/gim;
18:
19: async function detailsFromContext({ context }) {
20: if (!context || !context.payload || ...
{"actor":"getsantry[bot]","created_at":"2026-08-15T17:06:41Z","issue_author":null}
{"actor":"chadwhitacre","created_at":"2026-08-15T17:06:33Z","event":"mentioned","id":29503996753,"label":null}
{"actor":"chadwhitacre","created_at":"2026-08-15T17:06:33Z","event":"subscribed","id":29503996772,"label":null}
{"actor":"getsantry[bot]","created_at":"2026-08-15T17:06:41Z","event":"labeled","id":29503999078,"label":"accepted"}
{"actor":"getsantry[bot]","created_at":"2026-08-15T17:06:52Z","event":"labeled","id":29504001942,"label":"ci-pending"}
{"actor":"getsantry[bot]","created_at":"2026-08-15T17:16:07Z","event":"unlabeled","id":29504164338,"label":"ci-pending"}
{"actor":"getsantry[bot]","created_at":"2026-08-15T17:16:07Z","event":"labeled","id":29504164340,"label":"ci-ready"}
{"actor":"github-actions[bot]","created_at":"2026-08-15T17:18:55Z","event":"closed","id":29504211056,"label":null}
The hardening will use a bot-authored attestation tied to the latest accepted event and exact validated title. Publishing will reject if that event, title, actor, or attestation no longer match, so a rename requires a fresh approval.
Success. Updated the following files: A src/modules/approval-attestation.js M src/modules/details-from-context.js M src/modules/approval-authorizer.js
I am repairing the review findings: make approval proof immutable across title changes, make bot approval provenance explicit, and ensure rejected approval labels are always removed.
[
{
"content": "Map current approval workflow and test coverage against review findings",
"status": "in_progress",
"priority": "high"
},
{
"content": "Implement immutable approval attestation and fail-closed authorization",
"status": "pending",
"priority": "high"
},
{
"content": "Harden workflow cleanup and polling against approval drift",
"status": "pending",
"priority": "high"
},
{
"content": "Add adversarial regression tests and validate changes",
"status": "pending",
"priority": "high"
},
{
"content": "Process final security review findings",
"status": "pending",
"priority": "high"
}
]
<path>/home/byk/Code/getsentry/publish/.github/workflows/publish.yml</path>
<type>file</type>
<content>
1: name: Publish
2: on:
3: issues:
4: types: [labeled]
5:
6: concurrency:
7: # Use the issue title (e.g. "publish: getsentry/foo@1.2.3") so duplicate
8: # issues for the same repo@version share a concurrency group.
9: group: ${{ github.event.issue.title }}
10: cancel-in-progress: false
11:
12: permissions:
13: contents: read
14: issues: write
15: packages: write
16:
17: jobs:
18: # When accepted is added to a publish issue:
19: # - Add ci-pending (and remove ci-failed if retrying)
20: # - Enable the poller via CI_POLLER_HAS_PENDING=true
21: # - Comment on the issue
22: # - Trigger the poller immediately so we don't wait for the next cron tick
23: # The publish job below requires ci-ready, so it will not fire until the
24: # poller flips ci-pending → ci-ready (which also prevents publishing without
25: # CI verification in the ...
<path>/home/byk/Code/getsentry/publish/src/modules/approval-authorizer.js</path>
<type>file</type>
<content>
1: const ALLOWED_ROLE_NAMES = new Set(["write", "maintain", "admin"]);
2: const AUTO_APPROVER = "getsantry[bot]";
3: const { PUBLISH_TITLE_REGEX } = require("./details-from-context.js");
4:
5: async function authorizeApproval({
6: actor,
7: issueTitle,
8: getPermission,
9: autoApprovedRepositories = new Set(),
10: }) {
11: const title = issueTitle.match(PUBLISH_TITLE_REGEX);
12:
13: if (!title) {
14: return { authorized: false, repository: null };
15: }
16:
17: const repository = `getsentry/${title.groups.repo}`;
18: const releasePath = `${repository}${title.groups.path || ""}`;
19:
20: if (actor === AUTO_APPROVER && autoApprovedRepositories.has(releasePath)) {
21: return { authorized: true, repository };
22: }
23:
24: if (actor === AUTO_APPROVER) {
25: return { authorized: false, repository };
26: }
27:
28: const { role_name } = ...
<path>/home/byk/Code/getsentry/publish/src/publish/authorize-approval.js</path>
<type>file</type>
<content>
1: const fs = require("fs");
2: const { authorizeApproval } = require("../modules/approval-authorizer.js");
3:
4: function getAutoApprovedRepositories() {
5: return new Set(
6: fs
7: .readFileSync("auto-approve-repos.txt", "utf8")
8: .split(/\r?\n/)
9: .filter(Boolean)
10: );
11: }
12:
13: async function getPermission({ owner, repository, username }) {
14: const response = await fetch(
15: `https://api.github.com/repos/${encodeURIComponent(
16: owner
17: )}/${encodeURIComponent(repository)}/collaborators/${encodeURIComponent(
18: username
19: )}/permission`,
20: {
21: headers: {
22: Accept: "application/vnd.github+json",
23: Authorization: `Bearer ${process.env.APPROVAL_TOKEN}`,
24: "X-GitHub-Api-Version": "2026-03-10",
25: },
26: }
27: );
28:
29: if (!response.ok) {
30: ...
<path>/home/byk/Code/getsentry/publish/src/modules/__tests__/approval-authorizer.js</path>
<type>file</type>
<content>
1: import { describe, expect, test, vi } from "vitest";
2:
3: const { authorizeApproval } = require("../approval-authorizer.js");
4:
5: const issueTitle = "publish: getsentry/sentry-javascript@10.0.0";
6:
7: describe("authorizeApproval", () => {
8: test("allows the auto-approval bot for an allowlisted release without a target repository lookup", async () => {
9: const getPermission = vi.fn();
10:
11: await expect(
12: authorizeApproval({
13: actor: "getsantry[bot]",
14: issueTitle,
15: getPermission,
16: autoApprovedRepositories: new Set(["getsentry/sentry-javascript"]),
17: })
18: ).resolves.toEqual({
19: authorized: true,
20: repository: "getsentry/sentry-javascript",
21: });
22:
23: expect(getPermission).not.toHaveBeenCalled();
24: });
25:
26: test("rejects the auto-approval bot ...
<path>/home/byk/Code/getsentry/publish/src/publish/__tests__/authorize-approval.js</path>
<type>file</type>
<content>
1: import { afterEach, describe, expect, test, vi } from "vitest";
2: import fs from "fs";
3: const {
4: getAutoApprovedRepositories,
5: main,
6: } = require("../authorize-approval.js");
7:
8: const originalEnvironment = { ...process.env };
9:
10: afterEach(() => {
11: process.env = { ...originalEnvironment };
12: vi.restoreAllMocks();
13: vi.resetModules();
14: });
15:
16: async function runAuthorization({ actor, issueTitle, response }) {
17: process.env.GITHUB_OUTPUT = "/tmp/github-output";
18: process.env.APPROVAL_TOKEN = "release-bot-token";
19: process.env.APPROVAL_ACTOR = actor;
20: process.env.APPROVAL_ISSUE_TITLE = issueTitle;
21:
22: const appendFileSync = vi
23: .spyOn(fs, "appendFileSync")
24: .mockImplementation(() => {});
25: global.fetch = vi.fn().mockResolvedValue(response);
26:
27: await main();
28: await ...
<path>/home/byk/Code/getsentry/publish/.github/workflows/auto-approve.yml</path>
<type>file</type>
<content>
1: name: auto-approve non-sdks
2: on:
3: issues:
4: types: [opened]
5:
6: permissions:
7: contents: read
8:
9: jobs:
10: auto-approve:
11: runs-on: ubuntu-latest
12: if: |
13: (github.actor == 'sentry-release-bot[bot]' || github.actor == 'getsantry[bot]') &&
14: startsWith(github.event.issue.title, 'publish: ')
15: steps:
16: - uses: actions/checkout@v4
17: with:
18: sparse-checkout: auto-approve-repos.txt
19: sparse-checkout-cone-mode: false
20: - name: Get auth token
21: id: token
22: uses: actions/create-github-app-token@v3
23: with:
24: client-id: ${{ vars.SENTRY_INTERNAL_APP_ID }}
25: private-key: ${{ secrets.SENTRY_INTERNAL_APP_PRIVATE_KEY }}
26: - name: Auto-approve if repo is in the list
27: env:
28: GH_TOKEN: ${{ ...
<path>/home/byk/Code/getsentry/publish/.github/workflows/publish.yml</path> <type>file</type> <content> 138: publish: 139: runs-on: ubuntu-latest 140: environment: production 141: name: Publish a new version 142: # Publish when ci-ready is present (added by the poller after CI passes). 143: # Fires ONLY on ci-ready label events — not accepted — to avoid racing 144: # with waiting-for-ci on the same event. The poller always adds ci-ready 145: # after checking CI (even if ci-ready was already present, waiting-for-ci 146: # removes it first so a fresh labeled event fires), so this gate is 147: # guaranteed to trigger on the happy path. 148: if: >- 149: github.event.issue.state == 'open' 150: && github.event.label.name == 'ci-ready' 151: && contains(github.event.issue.labels.*.name, 'accepted') 152: && contains(github.event.issue.labels.*.name, 'ci-ready') 153: && !contains(github.event.issue.labels.*.name, ...
The initial gate used the label-event title but did not carry that snapshot into the later poller and publisher. I will add a signed-by-app, event-bound attestation and validate it before both CI transition and publish.
M .github/workflows/publish.yml
M AGENTS.md
M src/libs/__tests__/github.js
M src/libs/github.js
M src/modules/details-from-context.js
M src/modules/post-workflow-details.js
M src/modules/process-end-state.js
M src/modules/update-issue.js
M src/publish/inputs.js
M src/publish/post-result.js
M src/publish/post-workflow-details.js
M src/publish/update-issue.js
?? .lore.md
?? src/modules/__tests__/approval-authorizer.js
?? src/modules/approval-attestation.js
?? src/modules/approval-authorizer.js
?? src/publish/__tests__/
?? src/publish/authorize-approval.js
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index cff6bc3..081a17b 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -32,6 +32,11 @@ jobs:
&& github.event.issue.state == 'open'
&& startsWith(github.event.issue.title, 'publish: ')
steps:
+ - name: Get publish code
+ uses: actions/checkout@v6
+ with:
+ ...
<path>/home/byk/Code/getsentry/publish/src/modules/approval-attestation.js</path>
<type>file</type>
<content>
1: const ATTESTATION_PREFIX = "<!-- publish-approval ";
2: const ATTESTATION_SUFFIX = " -->";
3:
4: function createApprovalAttestation({ actor, eventId, title }) {
5: const value = Buffer.from(JSON.stringify({ actor, eventId: String(eventId), title }))
6: .toString("base64url");
7:
8: return `${ATTESTATION_PREFIX}${value}${ATTESTATION_SUFFIX}`;
9: }
10:
11: function parseApprovalAttestation(body) {
12: const start = body.indexOf(ATTESTATION_PREFIX);
13:
14: if (start === -1) {
15: return null;
16: }
17:
18: const end = body.indexOf(ATTESTATION_SUFFIX, start);
19:
20: if (end === -1) {
21: return null;
22: }
23:
24: try {
25: const value = JSON.parse(
26: Buffer.from(
27: body.slice(start + ATTESTATION_PREFIX.length, end),
28: "base64url"
29: ).toString("utf8")
30: );
31:
32: if (
33: typeof ...
<path>/home/byk/Code/getsentry/publish/.github/workflows/ci-poller.yml</path> <type>file</type> <content> 1: name: CI Status Poller 2: 3: on: 4: schedule: 5: - cron: "*/5 * * * *" 6: workflow_dispatch: 7: inputs: 8: attempt: 9: description: Self-dispatch attempt counter (internal use) 10: required: false 11: default: "0" 12: 13: permissions: 14: contents: read 15: issues: read 16: 17: jobs: 18: check-ci: 19: runs-on: ubuntu-latest 20: environment: production 21: # Skip entirely (no runner provisioned) when there's nothing to check. 22: # Set to "true" by ci-pending.yml, reset to "false" here when done. 23: # Always allow workflow_dispatch for manual recovery. 24: if: vars.CI_POLLER_HAS_PENDING == 'true' || github.event_name == 'workflow_dispatch' 25: concurrency: 26: group: ci-status-poller 27: cancel-in-progress: false 28: steps: 29: # sentry-internal-app token for label changes on ...
/home/byk/Code/getsentry/publish/src/publish/__tests__/authorize-approval.js /home/byk/Code/getsentry/publish/src/publish/authorize-approval.js /home/byk/Code/getsentry/publish/src/modules/approval-attestation.js /home/byk/Code/getsentry/publish/src/modules/approval-authorizer.js /home/byk/Code/getsentry/publish/src/modules/__tests__/approval-authorizer.js
Ripgrep JSON record exceeded 65536 bytes
I found the attestation module already staged as untracked scaffolding, but it is not wired into either workflow. I will complete that approach: automatic approvals get a trusted provenance record, authorized labels get an event-bound approval record, and the poller and publisher revalidate it against the live issue.
Success. Updated the following files: M src/modules/__tests__/approval-authorizer.js A src/modules/__tests__/approval-attestation.js
The authorization record will bind the exact accepted label event, actor, and title. Any later label re-addition or title mutation invalidates it, so CI and publication stop and remove accepted.
Success. Updated the following files: M src/modules/approval-attestation.js M src/modules/__tests__/approval-attestation.js M src/publish/authorize-approval.js A src/publish/validate-approval-attestation.js
I’m completing the attestation binding: approval will be checked again before CI handoff and before publication, with title changes and new accepted events invalidating the approval.
[
{
"content": "Map approval flow against final review findings",
"status": "in_progress",
"priority": "high"
},
{
"content": "Implement immutable approval attestation checks",
"status": "pending",
"priority": "high"
},
{
"content": "Add adversarial regression coverage",
"status": "pending",
"priority": "high"
},
{
"content": "Run validation and final reviews",
"status": "pending",
"priority": "high"
}
]
<path>/home/byk/Code/getsentry/publish/src/modules/approval-attestation.js</path>
<type>file</type>
<content>
1: const ATTESTATION_PREFIX = "<!-- publish-approval ";
2: const ATTESTATION_SUFFIX = " -->";
3:
4: function createApprovalAttestation({ actor, eventId, title }) {
5: const value = Buffer.from(JSON.stringify({ actor, eventId: String(eventId), title }))
6: .toString("base64url");
7:
8: return `${ATTESTATION_PREFIX}${value}${ATTESTATION_SUFFIX}`;
9: }
10:
11: function parseApprovalAttestation(body) {
12: const start = body.indexOf(ATTESTATION_PREFIX);
13:
14: if (start === -1) {
15: return null;
16: }
17:
18: const end = body.indexOf(ATTESTATION_SUFFIX, start);
19:
20: if (end === -1) {
21: return null;
22: }
23:
24: try {
25: const value = JSON.parse(
26: Buffer.from(
27: body.slice(start + ATTESTATION_PREFIX.length, end),
28: "base64url"
29: ).toString("utf8")
30: );
31:
32: if (
33: typeof ...
<path>/home/byk/Code/getsentry/publish/.github/workflows/publish.yml</path>
<type>file</type>
<content>
1: name: Publish
2: on:
3: issues:
4: types: [labeled]
5:
6: concurrency:
7: # Use the issue title (e.g. "publish: getsentry/foo@1.2.3") so duplicate
8: # issues for the same repo@version share a concurrency group.
9: group: ${{ github.event.issue.title }}
10: cancel-in-progress: false
11:
12: permissions:
13: contents: read
14: issues: write
15: packages: write
16:
17: jobs:
18: # When accepted is added to a publish issue:
19: # - Add ci-pending (and remove ci-failed if retrying)
20: # - Enable the poller via CI_POLLER_HAS_PENDING=true
21: # - Comment on the issue
22: # - Trigger the poller immediately so we don't wait for the next cron tick
23: # The publish job below requires ci-ready, so it will not fire until the
24: # poller flips ci-pending → ci-ready (which also prevents publishing without
25: # CI verification in the ...
<path>/home/byk/Code/getsentry/publish/src/publish/authorize-approval.js</path>
<type>file</type>
<content>
1: const fs = require("fs");
2: const {
3: createApprovalAttestation,
4: currentAcceptedEvent,
5: } = require("../modules/approval-attestation.js");
6: const { authorizeApproval } = require("../modules/approval-authorizer.js");
7:
8: function getAutoApprovedRepositories() {
9: return new Set(
10: fs
11: .readFileSync("auto-approve-repos.txt", "utf8")
12: .split(/\r?\n/)
13: .filter(Boolean)
14: );
15: }
16:
17: async function getPermission({ owner, repository, username }) {
18: const response = await getGitHubResponse(
19: `repos/${encodeURIComponent(owner)}/${encodeURIComponent(
20: repository
21: )}/collaborators/${encodeURIComponent(username)}/permission`
22: );
23:
24: if (!response.ok) {
25: throw new Error(
26: `Could not retrieve ${username}'s permission for ${owner}/${repository}: GitHub returned ...
<path>/home/byk/Code/getsentry/publish/.github/workflows/ci-poller.yml</path> <type>file</type> <content> 1: name: CI Status Poller 2: 3: on: 4: schedule: 5: - cron: "*/5 * * * *" 6: workflow_dispatch: 7: inputs: 8: attempt: 9: description: Self-dispatch attempt counter (internal use) 10: required: false 11: default: "0" 12: 13: permissions: 14: contents: read 15: issues: read 16: 17: jobs: 18: check-ci: 19: runs-on: ubuntu-latest 20: environment: production 21: # Skip entirely (no runner provisioned) when there's nothing to check. 22: # Set to "true" by ci-pending.yml, reset to "false" here when done. 23: # Always allow workflow_dispatch for manual recovery. 24: if: vars.CI_POLLER_HAS_PENDING == 'true' || github.event_name == 'workflow_dispatch' 25: concurrency: 26: group: ci-status-poller 27: cancel-in-progress: false 28: steps: 29: # sentry-internal-app token for label changes on ...
Found 9 matches
/home/byk/Code/getsentry/publish/.github/workflows/auto-approve.yml:
Line 24: client-id: ${{ vars.SENTRY_INTERNAL_APP_ID }}
Line 25: private-key: ${{ secrets.SENTRY_INTERNAL_APP_PRIVATE_KEY }}
/home/byk/Code/getsentry/publish/.github/workflows/ci-poller.yml:
Line 36: client-id: ${{ vars.SENTRY_INTERNAL_APP_ID }}
Line 37: private-key: ${{ secrets.SENTRY_INTERNAL_APP_PRIVATE_KEY }}
/home/byk/Code/getsentry/publish/.github/workflows/publish.yml:
Line 44: client-id: ${{ vars.SENTRY_INTERNAL_APP_ID }}
Line 45: private-key: ${{ secrets.SENTRY_INTERNAL_APP_PRIVATE_KEY }}
Line 58: - name: Authorize approval
Line 65: run: node src/publish/authorize-approval.js
Line 67: - name: Reject unauthorized approval or failed authorization
<path>/home/byk/Code/getsentry/publish/src/publish/__tests__/authorize-approval.js</path>
<type>file</type>
<content>
1: import { afterEach, describe, expect, test, vi } from "vitest";
2: import fs from "fs";
3: const {
4: getAutoApprovedRepositories,
5: main,
6: } = require("../authorize-approval.js");
7:
8: const originalEnvironment = { ...process.env };
9:
10: afterEach(() => {
11: process.env = { ...originalEnvironment };
12: vi.restoreAllMocks();
13: vi.resetModules();
14: });
15:
16: async function runAuthorization({ actor, issueTitle, response }) {
17: process.env.GITHUB_OUTPUT = "/tmp/github-output";
18: process.env.APPROVAL_TOKEN = "release-bot-token";
19: process.env.APPROVAL_ACTOR = actor;
20: process.env.APPROVAL_ISSUE_TITLE = issueTitle;
21:
22: const appendFileSync = vi
23: .spyOn(fs, "appendFileSync")
24: .mockImplementation(() => {});
25: global.fetch = vi.fn().mockResolvedValue(response);
26:
27: await main();
28: await ...
/home/byk/Code/getsentry/publish/src/publish/validate-approval-attestation.js /home/byk/Code/getsentry/publish/src/publish/__tests__/authorize-approval.js /home/byk/Code/getsentry/publish/src/publish/authorize-approval.js /home/byk/Code/getsentry/publish/src/modules/approval-attestation.js /home/byk/Code/getsentry/publish/src/modules/approval-authorizer.js /home/byk/Code/getsentry/publish/src/modules/__tests__/approval-attestation.js /home/byk/Code/getsentry/publish/src/modules/__tests__/approval-authorizer.js
No files found
<path>/home/byk/Code/getsentry/publish/src/modules/approval-authorizer.js</path>
<type>file</type>
<content>
1: const ALLOWED_ROLE_NAMES = new Set(["write", "maintain", "admin"]);
2: const AUTO_APPROVER = "getsantry[bot]";
3: const { PUBLISH_TITLE_REGEX } = require("./details-from-context.js");
4:
5: async function authorizeApproval({
6: actor,
7: issueTitle,
8: getPermission,
9: autoApprovedRepositories = new Set(),
10: }) {
11: const title = issueTitle.match(PUBLISH_TITLE_REGEX);
12:
13: if (!title) {
14: return { authorized: false, repository: null };
15: }
16:
17: const repository = `getsentry/${title.groups.repo}`;
18: const releasePath = `${repository}${title.groups.path || ""}`;
19:
20: if (actor === AUTO_APPROVER && autoApprovedRepositories.has(releasePath)) {
21: return { authorized: true, repository };
22: }
23:
24: if (actor === AUTO_APPROVER) {
25: return { authorized: false, repository };
26: }
27:
28: const { role_name } = ...
continue
The current attestation script exists but is not yet wired, and its entry-point tests lag its required issue context. I’m closing those gaps first, then will enforce the proof at both CI handoff and publication.
[
{
"content": "Map approval flow against final review findings",
"status": "completed",
"priority": "high"
},
{
"content": "Implement immutable approval attestation checks",
"status": "in_progress",
"priority": "high"
},
{
"content": "Add adversarial regression coverage",
"status": "pending",
"priority": "high"
},
{
"content": "Run validation and final reviews",
"status": "pending",
"priority": "high"
}
]
<path>/home/byk/Code/getsentry/publish/src/publish/validate-approval-attestation.js</path>
<type>file</type>
<content>
1: const fs = require("fs");
2: const {
3: currentAcceptedEvent,
4: hasApprovalAttestation,
5: } = require("../modules/approval-attestation.js");
6:
7: async function getGitHubResponse(path) {
8: return fetch(`https://api.github.com/${path}`, {
9: headers: {
10: Accept: "application/vnd.github+json",
11: Authorization: `Bearer ${process.env.APPROVAL_TOKEN}`,
12: "X-GitHub-Api-Version": "2026-03-10",
13: },
14: });
15: }
16:
17: async function getIssue({ repository, issueNumber }) {
18: const response = await getGitHubResponse(
19: `repos/${repository}/issues/${issueNumber}`
20: );
21:
22: if (!response.ok) {
23: throw new Error(
24: `Could not retrieve issue #${issueNumber}: GitHub returned ${response.status}`
25: );
26: }
27:
28: return response.json();
29: }
30:
31: async function getAllPages({ ...
<path>/home/byk/Code/getsentry/publish/src/modules/__tests__/approval-attestation.js</path>
<type>file</type>
<content>
1: import { describe, expect, test } from "vitest";
2:
3: const {
4: createApprovalAttestation,
5: currentAcceptedEvent,
6: hasApprovalAttestation,
7: parseApprovalAttestation,
8: } = require("../approval-attestation.js");
9:
10: describe("approval attestations", () => {
11: test("matches the latest accepted event to a trusted attestation", () => {
12: const attestation = createApprovalAttestation({
13: actor: "contractor",
14: eventId: "200",
15: title: "publish: getsentry/relay/py@1.2.3",
16: });
17: const event = currentAcceptedEvent([
18: {
19: actor: { login: "contractor" },
20: event: "labeled",
21: id: "100",
22: label: { name: "accepted" },
23: },
24: {
25: actor: { login: "contractor" },
26: event: "labeled",
27: id: "200",
28: label: { ...
{"body":"Approved. Checking CI status on the release branch. Publishing will start automatically when CI passes.","user":"github-actions[bot]"}
{"body":"CI checks passed for getsentry/sentry@26.8.0. Publishing is starting now.","user":"getsantry[bot]"}
{"body":"Publishing: [run#31897823685](https://github.com/getsentry/publish/actions/runs/31897823685)","user":"github-actions[bot]"}
{"body":"Published successfully: [run#31897823685](https://github.com/getsentry/publish/actions/runs/31897823685)","user":"github-actions[bot]"}
I’ll add a trusted, hidden approval record authored by github-actions[bot], then validate that record against the latest accepted event and live title before both downstream transitions. Rejection cleanup will use the workflow token under always() so failures before app-token setup still remove accepted.
Success. Updated the following files: M .github/workflows/publish.yml M .github/workflows/ci-poller.yml M src/publish/__tests__/authorize-approval.js A src/publish/__tests__/validate-approval-attestation.js
The workflow now records and validates the event-bound proof. I’m running the focused tests to resolve fixture/API sequencing problems before I validate YAML and the full suite.
yarn run v1.22.22 $ vitest run src/modules/__tests__/approval-attestation.js src/modules/__tests__/approval-authorizer.js src/publish/__tests__/authorize-approval.js src/publish/__tests__/validate-approval-attestation.js [1m[46m RUN [49m[22m [36mv4.0.17 [39m[90m/home/byk/Code/getsentry/publish[39m [32m✓[39m src/modules/__tests__/approval-authorizer.js [2m([22m[2m14 tests[22m[2m)[22m[32m 17[2mms[22m[39m [32m✓[39m src/publish/__tests__/authorize-approval.js [2m([22m[2m3 tests[22m[2m)[22m[32m 14[2mms[22m[39m [32m✓[39m src/publish/__tests__/validate-approval-attestation.js [2m([22m[2m2 tests[22m[2m)[22m[32m 17[2mms[22m[39m [32m✓[39m src/modules/__tests__/approval-attestation.js [2m([22m[2m2 tests[22m[2m)[22m[32m 5[2mms[22m[39m [2m Test Files [22m [1m[32m4 passed[39m[22m[90m (4)[39m [2m Tests [22m [1m[32m21 passed[39m[22m[90m (21)[39m [2m Start at [22m 19:18:35 [2m Duration [22m 686ms[2m (transform ...
[{"actor":"getsantry[bot]","id":29503999078,"id_type":"number"}]
Live issue events expose numeric IDs, while the new parser only accepted strings. I’m adding a fail-first regression for that API shape, then fixing the parser so valid approvals do not fail in production.
Success. Updated the following files: M src/modules/__tests__/approval-attestation.js
yarn run v1.22.22
$ vitest run src/modules/__tests__/approval-attestation.js
[1m[46m RUN [49m[22m [36mv4.0.17 [39m[90m/home/byk/Code/getsentry/publish[39m
[31m❯[39m src/modules/__tests__/approval-attestation.js [2m([22m[2m3 tests[22m[2m | [22m[31m1 failed[39m[2m)[22m[32m 17[2mms[22m[39m
[32m✓[39m matches the latest accepted event to a trusted attestation[32m 3[2mms[22m[39m
[31m [31m×[31m accepts numeric event IDs returned by GitHub's issue events API[39m[32m 8[2mms[22m[39m
[32m✓[39m rejects malformed or stale attestations[32m 1[2mms[22m[39m
[2m Test Files [22m [1m[31m1 failed[39m[22m[90m (1)[39m
[2m Tests [22m [1m[31m1 failed[39m[22m[2m | [22m[1m[32m2 passed[39m[22m[90m (3)[39m
[2m Start at [22m 19:25:22
[2m Duration [22m 244ms[2m (transform 25ms, setup 0ms, import 41ms, tests 17ms, environment 0ms)[22m
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command. ...
Success. Updated the following files: M src/modules/approval-attestation.js
yarn run v1.22.22 $ vitest run src/modules/__tests__/approval-attestation.js src/modules/__tests__/approval-authorizer.js src/publish/__tests__/authorize-approval.js src/publish/__tests__/validate-approval-attestation.js [1m[46m RUN [49m[22m [36mv4.0.17 [39m[90m/home/byk/Code/getsentry/publish[39m [32m✓[39m src/publish/__tests__/validate-approval-attestation.js [2m([22m[2m2 tests[22m[2m)[22m[32m 9[2mms[22m[39m [32m✓[39m src/modules/__tests__/approval-attestation.js [2m([22m[2m3 tests[22m[2m)[22m[32m 6[2mms[22m[39m [32m✓[39m src/modules/__tests__/approval-authorizer.js [2m([22m[2m14 tests[22m[2m)[22m[32m 30[2mms[22m[39m [32m✓[39m src/publish/__tests__/authorize-approval.js [2m([22m[2m3 tests[22m[2m)[22m[32m 13[2mms[22m[39m [2m Test Files [22m [1m[32m4 passed[39m[22m[90m (4)[39m [2m Tests [22m [1m[32m22 passed[39m[22m[90m (22)[39m [2m Start at [22m 19:28:22 [2m Duration [22m 600ms[2m (transform ...
I found one remaining revocation race: removing accepted after the ci-ready event could still leave a matching historical attestation. The live validation must also require that accepted remains on the issue.
Success. Updated the following files: M src/publish/validate-approval-attestation.js M src/publish/__tests__/validate-approval-attestation.js
yarn run v1.22.22 $ vitest run src/publish/__tests__/validate-approval-attestation.js [1m[46m RUN [49m[22m [36mv4.0.17 [39m[90m/home/byk/Code/getsentry/publish[39m [32m✓[39m src/publish/__tests__/validate-approval-attestation.js [2m([22m[2m3 tests[22m[2m)[22m[32m 9[2mms[22m[39m [2m Test Files [22m [1m[32m1 passed[39m[22m[90m (1)[39m [2m Tests [22m [1m[32m3 passed[39m[22m[90m (3)[39m [2m Start at [22m 19:33:16 [2m Duration [22m 242ms[2m (transform 30ms, setup 0ms, import 50ms, tests 9ms, environment 0ms)[22m Done in 0.72s.
<path>/home/byk/Code/getsentry/publish/src/modules/__tests__/approval-authorizer.js</path>
<type>file</type>
<content>
1: import { describe, expect, test, vi } from "vitest";
2:
3: const { authorizeApproval } = require("../approval-authorizer.js");
4:
5: const issueTitle = "publish: getsentry/sentry-javascript@10.0.0";
6:
7: describe("authorizeApproval", () => {
8: test("allows the auto-approval bot for an allowlisted release without a target repository lookup", async () => {
9: const getPermission = vi.fn();
10:
11: await expect(
12: authorizeApproval({
13: actor: "getsantry[bot]",
14: issueTitle,
15: getPermission,
16: autoApprovedRepositories: new Set(["getsentry/sentry-javascript"]),
17: })
18: ).resolves.toEqual({
19: authorized: true,
20: repository: "getsentry/sentry-javascript",
21: });
22:
23: expect(getPermission).not.toHaveBeenCalled();
24: });
25:
26: test("rejects the auto-approval bot ...
diff --git a/.github/workflows/ci-poller.yml b/.github/workflows/ci-poller.yml
index a3db430..0834608 100644
--- a/.github/workflows/ci-poller.yml
+++ b/.github/workflows/ci-poller.yml
@@ -26,6 +26,11 @@ jobs:
group: ci-status-poller
cancel-in-progress: false
steps:
+ - name: Get publish code
+ uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
# sentry-internal-app token for label changes on this repo.
# This token is what triggers publish.yml downstream — see
# https://github.com/getsentry/publish for why GITHUB_TOKEN can't.
@@ -92,6 +97,23 @@ jobs:
title=$(echo "$issue" | jq -r '.title')
body=$(echo "$issue" | jq -r '.body')
+ # The attestation binds the latest accepted-label event and title.
+ # Never move a release to ci-ready after either value changes.
+ if ! ...
The proof now blocks title changes and label removal. I’m adding the last adversarial case: re-adding accepted must invalidate the prior attestation even when the title and actor are unchanged.
Success. Updated the following files: M src/publish/__tests__/validate-approval-attestation.js
yarn run v1.22.22 $ vitest run src/publish/__tests__/validate-approval-attestation.js [1m[46m RUN [49m[22m [36mv4.0.17 [39m[90m/home/byk/Code/getsentry/publish[39m [32m✓[39m src/publish/__tests__/validate-approval-attestation.js [2m([22m[2m4 tests[22m[2m)[22m[32m 8[2mms[22m[39m [2m Test Files [22m [1m[32m1 passed[39m[22m[90m (1)[39m [2m Tests [22m [1m[32m4 passed[39m[22m[90m (4)[39m [2m Start at [22m 19:45:50 [2m Duration [22m 220ms[2m (transform 25ms, setup 0ms, import 41ms, tests 8ms, environment 0ms)[22m Done in 0.73s. yarn run v1.22.22 $ prettier --write src --check .github/workflows/publish.yml .github/workflows/ci-poller.yml src/modules/approval-attestation.js src/modules/__tests__/approval-attestation.js src/publish/authorize-approval.js src/publish/validate-approval-attestation.js src/publish/__tests__/authorize-approval.js src/publish/__tests__/validate-approval-attestation.js Checking formatting... Done in 0.81s. ...
The regression coverage now includes title mutation, label removal, label re-addition, and GitHub’s numeric event IDs. I’m formatting only the authorization files, then I’ll run the complete suite and workflow lint.
.github/workflows/publish.yml 115ms .github/workflows/ci-poller.yml 39ms src/modules/details-from-context.js 106ms src/modules/approval-attestation.js 41ms src/modules/approval-authorizer.js 14ms src/modules/__tests__/approval-attestation.js 17ms src/modules/__tests__/approval-authorizer.js 24ms src/publish/authorize-approval.js 34ms src/publish/validate-approval-attestation.js 15ms src/publish/__tests__/authorize-approval.js 19ms src/publish/__tests__/validate-approval-attestation.js 20ms Checking formatting... All matched files use Prettier code style!
yarn run v1.22.22 $ vitest run [1m[46m RUN [49m[22m [36mv4.0.17 [39m[90m/home/byk/Code/getsentry/publish[39m [32m✓[39m src/modules/__tests__/post-workflow-details.js [2m([22m[2m1 test[22m[2m)[22m[32m 14[2mms[22m[39m [32m✓[39m src/modules/__tests__/approval-authorizer.js [2m([22m[2m14 tests[22m[2m)[22m[32m 32[2mms[22m[39m [32m✓[39m src/publish/__tests__/authorize-approval.js [2m([22m[2m3 tests[22m[2m)[22m[32m 28[2mms[22m[39m [32m✓[39m src/modules/__tests__/update-issue.js [2m([22m[2m5 tests[22m[2m)[22m[32m 12[2mms[22m[39m [32m✓[39m src/publish/__tests__/validate-approval-attestation.js [2m([22m[2m4 tests[22m[2m)[22m[32m 9[2mms[22m[39m [32m✓[39m src/modules/__tests__/details-from-context.js [2m([22m[2m4 tests[22m[2m)[22m[32m 9[2mms[22m[39m [32m✓[39m src/modules/__tests__/process-end-state.js [2m([22m[2m4 tests[22m[2m)[22m[32m 49[2mms[22m[39m [32m✓[39m ...
The full suite passes. Lint found two errors in the new code, plus the unchanged cocoapods-keepalive.yml violation: I’ll remove the unused import and replace the unsupported BigInt call with a strict decimal-ID comparator.
Success. Updated the following files: M src/modules/approval-attestation.js M src/publish/validate-approval-attestation.js
yarn run v1.22.22 $ vitest run src/modules/__tests__/approval-attestation.js src/publish/__tests__/validate-approval-attestation.js [1m[46m RUN [49m[22m [36mv4.0.17 [39m[90m/home/byk/Code/getsentry/publish[39m [32m✓[39m src/modules/__tests__/approval-attestation.js [2m([22m[2m3 tests[22m[2m)[22m[32m 19[2mms[22m[39m [32m✓[39m src/publish/__tests__/validate-approval-attestation.js [2m([22m[2m4 tests[22m[2m)[22m[32m 22[2mms[22m[39m [2m Test Files [22m [1m[32m2 passed[39m[22m[90m (2)[39m [2m Tests [22m [1m[32m7 passed[39m[22m[90m (7)[39m [2m Start at [22m 19:53:27 [2m Duration [22m 350ms[2m (transform 88ms, setup 0ms, import 129ms, tests 41ms, environment 0ms)[22m Done in 0.84s. yarn run v1.22.22 $ eslint src .github --ignore-pattern '!.github' /home/byk/Code/getsentry/publish/.github/workflows/cocoapods-keepalive.yml 1:7 error Must use plain style scalar yml/plain-scalar ✖ 1 problem (1 error, 0 warnings) 1 ...
New-code lint now passes; repository-wide lint remains blocked only by the pre-existing scalar violation in cocoapods-keepalive.yml. I’m validating the edited workflows directly and running the complete suite before final independent reviews.
[
{
"content": "Map approval flow against final review findings",
"status": "completed",
"priority": "high"
},
{
"content": "Implement immutable approval attestation checks",
"status": "completed",
"priority": "high"
},
{
"content": "Add adversarial regression coverage",
"status": "completed",
"priority": "high"
},
{
"content": "Run validation and final reviews",
"status": "in_progress",
"priority": "high"
}
]
yarn run v1.22.22 $ vitest run [1m[46m RUN [49m[22m [36mv4.0.17 [39m[90m/home/byk/Code/getsentry/publish[39m [32m✓[39m src/publish/__tests__/authorize-approval.js [2m([22m[2m3 tests[22m[2m)[22m[32m 15[2mms[22m[39m [32m✓[39m src/modules/__tests__/approval-authorizer.js [2m([22m[2m14 tests[22m[2m)[22m[32m 50[2mms[22m[39m [32m✓[39m src/publish/__tests__/validate-approval-attestation.js [2m([22m[2m4 tests[22m[2m)[22m[32m 52[2mms[22m[39m [32m✓[39m src/modules/__tests__/approval-attestation.js [2m([22m[2m3 tests[22m[2m)[22m[32m 42[2mms[22m[39m [32m✓[39m src/modules/__tests__/post-workflow-details.js [2m([22m[2m1 test[22m[2m)[22m[32m 13[2mms[22m[39m [32m✓[39m src/modules/__tests__/process-end-state.js [2m([22m[2m4 tests[22m[2m)[22m[32m 96[2mms[22m[39m [32m✓[39m src/modules/__tests__/update-issue.js [2m([22m[2m5 tests[22m[2m)[22m[32m 15[2mms[22m[39m [32m✓[39m ...
All 40 tests pass, and the changed JavaScript is linted and formatted. I’m running separate correctness and security reviews of the final tree; repository-wide lint still has only the unrelated cocoapods-keepalive.yml error.
<task id="ses_fbb32b75affe6fDLmwvTL8iPpV" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
continue
Both final reviews are running. I will apply any findings, rerun the affected checks, and repeat the merge gate if the final tree changes.
Awaiting the two independent final reviews.
</task_result> </task>
The correctness reviewer returned no report, which cannot satisfy the merge gate. I’m replacing it with a substantive review while the independent security review continues.
<task id="ses_fbb25722bffe6VcAZUCaO5Vva8" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
The replacement correctness review and the security review are running. Their findings will determine the next edit and validation pass.
</task_result> </task>
The security reviewer also returned no report, so it does not clear the gate. I’m replacing it with a review that must provide line-cited findings or explicit passes.
<task id="ses_fbb21c22effeAavAYTbRleeofj" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
A substantive security review is running alongside the prior replacement correctness review.
</task_result> </task>
The two security-review attempts produced no report, so they do not clear the gate. I’m checking the automated-approval provenance and workflow token semantics directly while the correctness reviewer remains active.
<path>/home/byk/Code/getsentry/publish/.github/workflows/auto-approve.yml</path>
<type>file</type>
<content>
1: name: auto-approve non-sdks
2: on:
3: issues:
4: types: [opened]
5:
6: permissions:
7: contents: read
8:
9: jobs:
10: auto-approve:
11: runs-on: ubuntu-latest
12: if: |
13: (github.actor == 'sentry-release-bot[bot]' || github.actor == 'getsantry[bot]') &&
14: startsWith(github.event.issue.title, 'publish: ')
15: steps:
16: - uses: actions/checkout@v4
17: with:
18: sparse-checkout: auto-approve-repos.txt
19: sparse-checkout-cone-mode: false
20: - name: Get auth token
21: id: token
22: uses: actions/create-github-app-token@v3
23: with:
24: client-id: ${{ vars.SENTRY_INTERNAL_APP_ID }}
25: private-key: ${{ secrets.SENTRY_INTERNAL_APP_PRIVATE_KEY }}
26: - name: Auto-approve if repo is in the list
27: env:
28: GH_TOKEN: ${{ ...
<path>/home/byk/Code/getsentry/publish/.github/workflows/publish.yml</path>
<type>file</type>
<content>
1: name: Publish
2: on:
3: issues:
4: types: [labeled]
5:
6: concurrency:
7: # Use the issue title (e.g. "publish: getsentry/foo@1.2.3") so duplicate
8: # issues for the same repo@version share a concurrency group.
9: group: ${{ github.event.issue.title }}
10: cancel-in-progress: false
11:
12: permissions:
13: contents: read
14: issues: write
15: packages: write
16:
17: jobs:
18: # When accepted is added to a publish issue:
19: # - Add ci-pending (and remove ci-failed if retrying)
20: # - Enable the poller via CI_POLLER_HAS_PENDING=true
21: # - Comment on the issue
22: # - Trigger the poller immediately so we don't wait for the next cron tick
23: # The publish job below requires ci-ready, so it will not fire until the
24: # poller flips ci-pending → ci-ready (which also prevents publishing without
25: # CI verification in the ...
<path>/home/byk/Code/getsentry/publish/src/publish/authorize-approval.js</path>
<type>file</type>
<content>
1: const fs = require("fs");
2: const {
3: createApprovalAttestation,
4: currentAcceptedEvent,
5: } = require("../modules/approval-attestation.js");
6: const { authorizeApproval } = require("../modules/approval-authorizer.js");
7:
8: function getAutoApprovedRepositories() {
9: return new Set(
10: fs
11: .readFileSync("auto-approve-repos.txt", "utf8")
12: .split(/\r?\n/)
13: .filter(Boolean)
14: );
15: }
16:
17: async function getPermission({ owner, repository, username }) {
18: const response = await getGitHubResponse(
19: `repos/${encodeURIComponent(owner)}/${encodeURIComponent(
20: repository
21: )}/collaborators/${encodeURIComponent(username)}/permission`
22: );
23:
24: if (!response.ok) {
25: throw new Error(
26: `Could not retrieve ${username}'s permission for ${owner}/${repository}: GitHub returned ...
Found 18 matches /home/byk/Code/getsentry/publish/.lore.md: Line 8: * **approval-authorizer GitHub roles**: Chose live GitHub collaborator permission checks over private \`security-as-code\` because Publish is public and GitHub reflects effective target-repository access without exposing RBAC. Humans authorize only with \`write\`, \`maintain\`, or \`admin\`; \`triage\` never authorizes. \`getsantry\[bot]\` must never fall through to collaborator lookup: it authorizes only when its exact parsed repository/release path is in \`auto-approve-repos.txt\`, because bot repository access does not prove the event followed an allowlisted auto-approval path. Line 19: * **auto-approve.yml label actor**: Trap: creating an internal-app token to add \`accepted\` looks appropriate because it has label permission, but GitHub records the app as the label-event actor rather than \`getsantry\[bot]\`. An approval gate that authorizes \`github.actor\` can reject legitimate allowlisted automation or ...
The direct audit confirmed the remaining automation flaw: auto-approve.yml applies accepted with the internal app token, so the label event cannot safely identify getsantry[bot]. I’m adding a trusted pre-label provenance record for exact allowlisted releases, then will let authorization consume it only for the internal-app event.
<path>/home/byk/Code/getsentry/publish/src/modules/approval-attestation.js</path>
<type>file</type>
<content>
1: const ATTESTATION_PREFIX = "<!-- publish-approval ";
2: const ATTESTATION_SUFFIX = " -->";
3:
4: function createApprovalAttestation({ actor, eventId, title }) {
5: const value = Buffer.from(
6: JSON.stringify({ actor, eventId: String(eventId), title })
7: ).toString("base64url");
8:
9: return `${ATTESTATION_PREFIX}${value}${ATTESTATION_SUFFIX}`;
10: }
11:
12: function parseApprovalAttestation(body) {
13: const start = body.indexOf(ATTESTATION_PREFIX);
14:
15: if (start === -1) {
16: return null;
17: }
18:
19: const end = body.indexOf(ATTESTATION_SUFFIX, start);
20:
21: if (end === -1) {
22: return null;
23: }
24:
25: try {
26: const value = JSON.parse(
27: Buffer.from(
28: body.slice(start + ATTESTATION_PREFIX.length, end),
29: "base64url"
30: ).toString("utf8")
31: );
32:
33: if (
34: ...
<path>/home/byk/Code/getsentry/publish/.github/workflows/ci-poller.yml</path> <type>file</type> <content> 1: name: CI Status Poller 2: 3: on: 4: schedule: 5: - cron: "*/5 * * * *" 6: workflow_dispatch: 7: inputs: 8: attempt: 9: description: Self-dispatch attempt counter (internal use) 10: required: false 11: default: "0" 12: 13: permissions: 14: contents: read 15: issues: read 16: 17: jobs: 18: check-ci: 19: runs-on: ubuntu-latest 20: environment: production 21: # Skip entirely (no runner provisioned) when there's nothing to check. 22: # Set to "true" by ci-pending.yml, reset to "false" here when done. 23: # Always allow workflow_dispatch for manual recovery. 24: if: vars.CI_POLLER_HAS_PENDING == 'true' || github.event_name == 'workflow_dispatch' 25: concurrency: 26: group: ci-status-poller 27: cancel-in-progress: false 28: steps: 29: - name: Get publish code 30: uses: ...
<path>/home/byk/Code/getsentry/publish/README.md</path>
<type>file</type>
<content>
70: required: false
71:
72: jobs:
73: release:
74: uses: getsentry/craft/.github/workflows/release.yml@v2
75: with:
76: version: ${{ inputs.version }}
77: merge_target: ${{ inputs.merge_target }}
78: secrets: inherit
79: ```
80:
81: The same `merge_target` input is also available when using the [Craft composite action](https://craft.sentry.dev/github-actions/#option-2-composite-action) directly.
82:
83: ## Approvals
84:
85: Packages we release into the wider world that our customers install, require an explicit approval. This for instance applies to
86: `sentry-cli`, our SDKs or the `symbolicator` distributed utilities. Internal dependencies such as `arroyo` can be published
87: with an auto approval. The reasoning here is that the bump of the dependency requires an explicit approval again in Sentry
88: proper. ...
I’m binding auto-approval to an internal-app-authored, pre-label record that captures the original release bot and exact live title. The authorizer will accept the internal-app label event only when that record exists for an allowlisted release.
<path>/home/byk/Code/getsentry/publish/src/publish/__tests__/authorize-approval.js</path>
<type>file</type>
<content>
1: import { afterEach, describe, expect, test, vi } from "vitest";
2: import fs from "fs";
3: const {
4: getAutoApprovedRepositories,
5: main,
6: } = require("../authorize-approval.js");
7:
8: const originalEnvironment = { ...process.env };
9:
10: afterEach(() => {
11: process.env = { ...originalEnvironment };
12: vi.restoreAllMocks();
13: vi.resetModules();
14: });
15:
16: function jsonResponse(json) {
17: return { ok: true, json: vi.fn().mockResolvedValue(json) };
18: }
19:
20: async function runAuthorization({ actor, issueTitle, responses }) {
21: process.env.GITHUB_OUTPUT = "/tmp/github-output";
22: process.env.APPROVAL_TOKEN = "release-bot-token";
23: process.env.APPROVAL_ACTOR = actor;
24: process.env.APPROVAL_ISSUE_NUMBER = "123";
25: process.env.APPROVAL_ISSUE_REPOSITORY = "getsentry/publish";
26: process.env.APPROVAL_ISSUE_TITLE = ...
<path>/home/byk/Code/getsentry/publish/src/modules/approval-authorizer.js</path>
<type>file</type>
<content>
1: const ALLOWED_ROLE_NAMES = new Set(["write", "maintain", "admin"]);
2: const AUTO_APPROVER = "getsantry[bot]";
3: const { PUBLISH_TITLE_REGEX } = require("./details-from-context.js");
4:
5: async function authorizeApproval({
6: actor,
7: issueTitle,
8: getPermission,
9: autoApprovedRepositories = new Set(),
10: }) {
11: const title = issueTitle.match(PUBLISH_TITLE_REGEX);
12:
13: if (!title) {
14: return { authorized: false, repository: null };
15: }
16:
17: const repository = `getsentry/${title.groups.repo}`;
18: const releasePath = `${repository}${title.groups.path || ""}`;
19:
20: if (actor === AUTO_APPROVER && autoApprovedRepositories.has(releasePath)) {
21: return { authorized: true, repository };
22: }
23:
24: if (actor === AUTO_APPROVER) {
25: return { authorized: false, repository };
26: }
27:
28: const { role_name } = ...
<path>/home/byk/Code/getsentry/publish/src/modules/__tests__/approval-attestation.js</path>
<type>file</type>
<content>
1: import { describe, expect, test } from "vitest";
2:
3: const {
4: createApprovalAttestation,
5: currentAcceptedEvent,
6: hasApprovalAttestation,
7: parseApprovalAttestation,
8: } = require("../approval-attestation.js");
9:
10: describe("approval attestations", () => {
11: test("matches the latest accepted event to a trusted attestation", () => {
12: const attestation = createApprovalAttestation({
13: actor: "contractor",
14: eventId: "200",
15: title: "publish: getsentry/relay/py@1.2.3",
16: });
17: const event = currentAcceptedEvent([
18: {
19: actor: { login: "contractor" },
20: event: "labeled",
21: id: "100",
22: label: { name: "accepted" },
23: },
24: {
25: actor: { login: "contractor" },
26: event: "labeled",
27: id: "200",
28: label: { ...
30127721394 sentry-junior[bot] https://api.github.com/repos/getsentry/publish/issues/9365 30125783461 BYK https://api.github.com/repos/getsentry/publish/issues/9364 30118737836 getsantry[bot] https://api.github.com/repos/getsentry/publish/issues/9363 30117262739 getsantry[bot] https://api.github.com/repos/getsentry/publish/issues/9362 30115600805 getsantry[bot] https://api.github.com/repos/getsentry/publish/issues/9361 30114374562 getsantry[bot] https://api.github.com/repos/getsentry/publish/issues/9360 30110879532 getsantry[bot] https://api.github.com/repos/getsentry/publish/issues/9359 30105020188 runningcode https://api.github.com/repos/getsentry/publish/issues/9356 30105019414 Litarnus https://api.github.com/repos/getsentry/publish/issues/9357 30101156858 markushi https://api.github.com/repos/getsentry/publish/issues/9354 30100304680 NinjaLikesCheez https://api.github.com/repos/getsentry/publish/issues/9348 30098331858 antonis https://api.github.com/repos/getsentry/publish/issues/9...
GitHub’s live issue-event API shows that accepted label events from this workflow are attributed to getsantry[bot], not the internal app. The existing narrow allowlist path therefore receives the correct event actor; I’m keeping the stricter provenance gate and requesting a substantive security review.
</task_result> </task>
The replacement reviewer also returned no findings, so it does not satisfy the review gate. I’m inspecting the final working tree and approval path directly, then I’ll run the full validation suite before requesting any further review.
[
{
"content": "Map approval flow against final review findings",
"status": "completed",
"priority": "high"
},
{
"content": "Implement immutable approval attestation checks",
"status": "completed",
"priority": "high"
},
{
"content": "Add adversarial regression coverage",
"status": "completed",
"priority": "high"
},
{
"content": "Inspect final approval path and run validation",
"status": "in_progress",
"priority": "high"
},
{
"content": "Obtain substantive correctness and security reviews",
"status": "pending",
"priority": "high"
}
]
M .github/workflows/ci-poller.yml M .github/workflows/publish.yml M AGENTS.md M src/libs/__tests__/github.js M src/libs/github.js M src/modules/details-from-context.js M src/modules/post-workflow-details.js M src/modules/process-end-state.js M src/modules/update-issue.js M src/publish/inputs.js M src/publish/post-result.js M src/publish/post-workflow-details.js M src/publish/update-issue.js ?? .lore.md ?? src/modules/__tests__/approval-attestation.js ?? src/modules/__tests__/approval-authorizer.js ?? src/modules/approval-attestation.js ?? src/modules/approval-authorizer.js ?? src/publish/__tests__/ ?? src/publish/authorize-approval.js ?? src/publish/validate-approval-attestation.js error Command failed with exit code 1. yarn run v1.22.22 $ vitest run [1m[46m RUN [49m[22m [36mv4.0.17 [39m[90m/home/byk/Code/getsentry/publish[39m [32m✓[39m src/modules/__tests__/approval-attestation.js [2m([22m[2m3 tests[22m[2m)[22m[32m 18[2mms[22m[39m [32m✓[39m ...
<path>/home/byk/Code/getsentry/publish/.github/workflows/publish.yml</path>
<type>file</type>
<content>
1: name: Publish
2: on:
3: issues:
4: types: [labeled]
5:
6: concurrency:
7: # Use the issue title (e.g. "publish: getsentry/foo@1.2.3") so duplicate
8: # issues for the same repo@version share a concurrency group.
9: group: ${{ github.event.issue.title }}
10: cancel-in-progress: false
11:
12: permissions:
13: contents: read
14: issues: write
15: packages: write
16:
17: jobs:
18: # When accepted is added to a publish issue:
19: # - Add ci-pending (and remove ci-failed if retrying)
20: # - Enable the poller via CI_POLLER_HAS_PENDING=true
21: # - Comment on the issue
22: # - Trigger the poller immediately so we don't wait for the next cron tick
23: # The publish job below requires ci-ready, so it will not fire until the
24: # poller flips ci-pending → ci-ready (which also prevents publishing without
25: # CI verification in the ...
continue
I’m reviewing the authorization paths for races and privilege boundaries, then I’ll make any remaining fail-closed fixes and rerun the focused suite.
<path>/home/byk/Code/getsentry/publish/src/publish/authorize-approval.js</path>
<type>file</type>
<content>
1: const fs = require("fs");
2: const {
3: createApprovalAttestation,
4: currentAcceptedEvent,
5: } = require("../modules/approval-attestation.js");
6: const { authorizeApproval } = require("../modules/approval-authorizer.js");
7:
8: function getAutoApprovedRepositories() {
9: return new Set(
10: fs
11: .readFileSync("auto-approve-repos.txt", "utf8")
12: .split(/\r?\n/)
13: .filter(Boolean)
14: );
15: }
16:
17: async function getPermission({ owner, repository, username }) {
18: const response = await getGitHubResponse(
19: `repos/${encodeURIComponent(owner)}/${encodeURIComponent(
20: repository
21: )}/collaborators/${encodeURIComponent(username)}/permission`
22: );
23:
24: if (!response.ok) {
25: throw new Error(
26: `Could not retrieve ${username}'s permission for ${owner}/${repository}: GitHub returned ...
<path>/home/byk/Code/getsentry/publish/src/publish/validate-approval-attestation.js</path>
<type>file</type>
<content>
1: const {
2: currentAcceptedEvent,
3: hasApprovalAttestation,
4: } = require("../modules/approval-attestation.js");
5:
6: async function getGitHubResponse(path) {
7: return fetch(`https://api.github.com/${path}`, {
8: headers: {
9: Accept: "application/vnd.github+json",
10: Authorization: `Bearer ${process.env.APPROVAL_TOKEN}`,
11: "X-GitHub-Api-Version": "2026-03-10",
12: },
13: });
14: }
15:
16: async function getIssue({ repository, issueNumber }) {
17: const response = await getGitHubResponse(
18: `repos/${repository}/issues/${issueNumber}`
19: );
20:
21: if (!response.ok) {
22: throw new Error(
23: `Could not retrieve issue #${issueNumber}: GitHub returned ${response.status}`
24: );
25: }
26:
27: return response.json();
28: }
29:
30: async function getAllPages({ repository, issueNumber, resource ...
<path>/home/byk/Code/getsentry/publish/src/modules/approval-attestation.js</path>
<type>file</type>
<content>
1: const ATTESTATION_PREFIX = "<!-- publish-approval ";
2: const ATTESTATION_SUFFIX = " -->";
3:
4: function createApprovalAttestation({ actor, eventId, title }) {
5: const value = Buffer.from(
6: JSON.stringify({ actor, eventId: String(eventId), title })
7: ).toString("base64url");
8:
9: return `${ATTESTATION_PREFIX}${value}${ATTESTATION_SUFFIX}`;
10: }
11:
12: function parseApprovalAttestation(body) {
13: const start = body.indexOf(ATTESTATION_PREFIX);
14:
15: if (start === -1) {
16: return null;
17: }
18:
19: const end = body.indexOf(ATTESTATION_SUFFIX, start);
20:
21: if (end === -1) {
22: return null;
23: }
24:
25: try {
26: const value = JSON.parse(
27: Buffer.from(
28: body.slice(start + ATTESTATION_PREFIX.length, end),
29: "base64url"
30: ).toString("utf8")
31: );
32:
33: if (
34: ...
<path>/home/byk/Code/getsentry/publish/.github/workflows/publish.yml</path>
<type>file</type>
<content>
340: # `Set targets` step above.
341: XDG_STATE_HOME: /github/workspace/.craft-state
342: CRAFT_MERGE_TARGET: ${{ fromJSON(steps.inputs.outputs.result).merge_target }}
343: CRAFT_LOG_LEVEL: ${{ vars.CRAFT_LOG_LEVEL || 'Info' }}
344: CRAFT_DRY_RUN: ${{ fromJSON(steps.inputs.outputs.result).dry_run }}
345: GIT_COMMITTER_NAME: sentry-release-bot[bot]
346: GIT_AUTHOR_NAME: sentry-release-bot[bot]
347: EMAIL: 180476844+sentry-release-bot[bot]@users.noreply.github.com
348: GITHUB_TOKEN: ${{ steps.token.outputs.token }}
349: # We need to use separate tokens for GHCR.IO and GitHub API access
350: # Because we can only access ghcr.io with GITHUB_TOKEN but that token
351: # cannot do other cross-repo operations like our Release Bot App
352: # Thanks GitHub
353: ...
<path>/home/byk/Code/getsentry/publish/src/modules/details-from-context.js</path> <type>file</type> <content> 1: /** 2: * Matches the entire "Targets" section of a github publish issue body. 3: */ 4: const TARGETS_SECTION_PARSER_REGEX = 5: /^(?!### Targets$\s)(?: *- \[[ xX]\] \S+\s*$(?:\r?\n)?)+/m; 6: 7: /** 8: * Matches all targets of a github publish issue body in a section that was already matched and extracted with `TARGETS_PARSER_REGEX`. 9: * The "id" of the targets is captured within a capture group. 10: */ 11: const TARGETS_PARSER_REGEX = /^\s*- \[[ x]\] (\S+)/gim; 12: 13: /** 14: * Matches checked targets of a github publish issue body in a section that was already matched and extracted with `TARGETS_PARSER_REGEX`. 15: * The "id" of the targets is captured within a capture group. 16: */ 17: const CHECKED_TARGETS_PARSER_REGEX = /^\s*- \[x\] (\S+)/gim; 18: const PUBLISH_TITLE_REGEX = 19: /^publish: ...
<path>/home/byk/Code/getsentry/publish/src/publish/inputs.js</path>
<type>file</type>
<content>
1: const github = require("@actions/github");
2: const core = require("@actions/core");
3: const { detailsFromContext } = require("../modules/details-from-context");
4:
5: async function inputs() {
6: const result = await detailsFromContext({
7: context: github.context,
8: });
9: core.setOutput("result", result);
10: }
11:
12: inputs();
(End of file - total 12 lines)
</content>
Found 23 matches
/home/byk/Code/getsentry/publish/.github/workflows/publish.yml:
Line 14: issues: write
Line 77: gh issue comment "${{ github.event.issue.number }}" \
Line 87: APPROVAL_ATTESTATION_AUTHOR: github-actions[bot]
Line 105: gh issue edit "${{ github.event.issue.number }}" \
Line 108: gh issue comment "${{ github.event.issue.number }}" \
Line 125: gh issue edit "${{ github.event.issue.number }}" \
Line 140: gh issue comment "${{ github.event.issue.number }}" \
Line 201: APPROVAL_ATTESTATION_AUTHOR: github-actions[bot]
Line 212: gh issue edit "${{ github.event.issue.number }}" \
Line 215: gh issue comment "${{ github.event.issue.number }}" \
/home/byk/Code/getsentry/publish/.github/workflows/auto-approve.yml:
Line 34: gh issue edit "$ISSUE_URL" --add-label accepted
/home/byk/Code/getsentry/publish/.github/workflows/ci-poller.yml:
Line ...
<path>/home/byk/Code/getsentry/publish/src/publish/__tests__/validate-approval-attestation.js</path>
<type>file</type>
<content>
1: import { afterEach, describe, expect, test, vi } from "vitest";
2:
3: const {
4: validateApprovalAttestation,
5: } = require("../validate-approval-attestation.js");
6: const {
7: createApprovalAttestation,
8: } = require("../../modules/approval-attestation.js");
9:
10: afterEach(() => {
11: vi.restoreAllMocks();
12: });
13:
14: function jsonResponse(json) {
15: return { ok: true, json: vi.fn().mockResolvedValue(json) };
16: }
17:
18: describe("validateApprovalAttestation", () => {
19: test("accepts the current accepted event and its trusted attestation", async () => {
20: const title = "publish: getsentry/sentry-javascript@10.0.0";
21: const attestation = createApprovalAttestation({
22: actor: "contractor",
23: eventId: "100",
24: title,
25: });
26: global.fetch = vi
27: .fn()
28: ...
diff --git a/.github/workflows/ci-poller.yml b/.github/workflows/ci-poller.yml
index a3db430..0834608 100644
--- a/.github/workflows/ci-poller.yml
+++ b/.github/workflows/ci-poller.yml
@@ -26,6 +26,11 @@ jobs:
group: ci-status-poller
cancel-in-progress: false
steps:
+ - name: Get publish code
+ uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
# sentry-internal-app token for label changes on this repo.
# This token is what triggers publish.yml downstream — see
# https://github.com/getsentry/publish for why GITHUB_TOKEN can't.
@@ -92,6 +97,23 @@ jobs:
title=$(echo "$issue" | jq -r '.title')
body=$(echo "$issue" | jq -r '.body')
+ # The attestation binds the latest accepted-label event and title.
+ # Never move a release to ci-ready after either value changes.
+ if ! ...
<path>/home/byk/Code/getsentry/publish/src/modules/__tests__/approval-authorizer.js</path>
<type>file</type>
<content>
1: import { describe, expect, test, vi } from "vitest";
2:
3: const { authorizeApproval } = require("../approval-authorizer.js");
4:
5: const issueTitle = "publish: getsentry/sentry-javascript@10.0.0";
6:
7: describe("authorizeApproval", () => {
8: test("allows the auto-approval bot for an allowlisted release without a target repository lookup", async () => {
9: const getPermission = vi.fn();
10:
11: await expect(
12: authorizeApproval({
13: actor: "getsantry[bot]",
14: issueTitle,
15: getPermission,
16: autoApprovedRepositories: new Set(["getsentry/sentry-javascript"]),
17: })
18: ).resolves.toEqual({
19: authorized: true,
20: repository: "getsentry/sentry-javascript",
21: });
22:
23: expect(getPermission).not.toHaveBeenCalled();
24: });
25:
26: test("rejects the auto-approval bot ...
The final review exposed one remaining handoff gap: the poller validates approval before its CI work, but it can add ci-ready after the bound label event or title changes. I’m revalidating immediately before that transition and making the poller consume Publish’s shared title parser.
continue
I’m completing the poller’s final approval check, then I’ll add a regression test and rerun the full validation suite.
[
{
"content": "Inspect current approval workflow and final handoff gap",
"status": "in_progress",
"priority": "high"
},
{
"content": "Revalidate approval immediately before ci-ready",
"status": "pending",
"priority": "high"
},
{
"content": "Add regression coverage for approval drift",
"status": "pending",
"priority": "high"
},
{
"content": "Run full validation and substantive reviews",
"status": "pending",
"priority": "high"
}
]
<path>/home/byk/Code/getsentry/publish/.github/workflows/ci-poller.yml</path> <type>file</type> <content> 1: name: CI Status Poller 2: 3: on: 4: schedule: 5: - cron: "*/5 * * * *" 6: workflow_dispatch: 7: inputs: 8: attempt: 9: description: Self-dispatch attempt counter (internal use) 10: required: false 11: default: "0" 12: 13: permissions: 14: contents: read 15: issues: read 16: 17: jobs: 18: check-ci: 19: runs-on: ubuntu-latest 20: environment: production 21: # Skip entirely (no runner provisioned) when there's nothing to check. 22: # Set to "true" by ci-pending.yml, reset to "false" here when done. 23: # Always allow workflow_dispatch for manual recovery. 24: if: vars.CI_POLLER_HAS_PENDING == 'true' || github.event_name == 'workflow_dispatch' 25: concurrency: 26: group: ci-status-poller 27: cancel-in-progress: false 28: steps: 29: - name: Get publish code 30: uses: ...
<path>/home/byk/Code/getsentry/publish/src/publish/validate-approval-attestation.js</path>
<type>file</type>
<content>
1: const {
2: currentAcceptedEvent,
3: hasApprovalAttestation,
4: } = require("../modules/approval-attestation.js");
5:
6: async function getGitHubResponse(path) {
7: return fetch(`https://api.github.com/${path}`, {
8: headers: {
9: Accept: "application/vnd.github+json",
10: Authorization: `Bearer ${process.env.APPROVAL_TOKEN}`,
11: "X-GitHub-Api-Version": "2026-03-10",
12: },
13: });
14: }
15:
16: async function getIssue({ repository, issueNumber }) {
17: const response = await getGitHubResponse(
18: `repos/${repository}/issues/${issueNumber}`
19: );
20:
21: if (!response.ok) {
22: throw new Error(
23: `Could not retrieve issue #${issueNumber}: GitHub returned ${response.status}`
24: );
25: }
26:
27: return response.json();
28: }
29:
30: async function getAllPages({ repository, issueNumber, resource ...
<path>/home/byk/Code/getsentry/publish/src/modules/details-from-context.js</path> <type>file</type> <content> 1: /** 2: * Matches the entire "Targets" section of a github publish issue body. 3: */ 4: const TARGETS_SECTION_PARSER_REGEX = 5: /^(?!### Targets$\s)(?: *- \[[ xX]\] \S+\s*$(?:\r?\n)?)+/m; 6: 7: /** 8: * Matches all targets of a github publish issue body in a section that was already matched and extracted with `TARGETS_PARSER_REGEX`. 9: * The "id" of the targets is captured within a capture group. 10: */ 11: const TARGETS_PARSER_REGEX = /^\s*- \[[ x]\] (\S+)/gim; 12: 13: /** 14: * Matches checked targets of a github publish issue body in a section that was already matched and extracted with `TARGETS_PARSER_REGEX`. 15: * The "id" of the targets is captured within a capture group. 16: */ 17: const CHECKED_TARGETS_PARSER_REGEX = /^\s*- \[x\] (\S+)/gim; 18: const PUBLISH_TITLE_REGEX = 19: /^publish: ...
/home/byk/Code/getsentry/publish/src/publish/validate-approval-attestation.js /home/byk/Code/getsentry/publish/src/publish/authorize-approval.js /home/byk/Code/getsentry/publish/src/publish/update-issue.js /home/byk/Code/getsentry/publish/src/publish/post-workflow-details.js /home/byk/Code/getsentry/publish/src/publish/post-result.js /home/byk/Code/getsentry/publish/src/publish/inputs.js
<path>/home/byk/Code/getsentry/publish/src/publish/__tests__/validate-approval-attestation.js</path>
<type>file</type>
<content>
1: import { afterEach, describe, expect, test, vi } from "vitest";
2:
3: const {
4: validateApprovalAttestation,
5: } = require("../validate-approval-attestation.js");
6: const {
7: createApprovalAttestation,
8: } = require("../../modules/approval-attestation.js");
9:
10: afterEach(() => {
11: vi.restoreAllMocks();
12: });
13:
14: function jsonResponse(json) {
15: return { ok: true, json: vi.fn().mockResolvedValue(json) };
16: }
17:
18: describe("validateApprovalAttestation", () => {
19: test("accepts the current accepted event and its trusted attestation", async () => {
20: const title = "publish: getsentry/sentry-javascript@10.0.0";
21: const attestation = createApprovalAttestation({
22: actor: "contractor",
23: eventId: "100",
24: title,
25: });
26: global.fetch = vi
27: .fn()
28: ...
<path>/home/byk/Code/getsentry/publish/src/modules/__tests__/approval-authorizer.js</path>
<type>file</type>
<content>
1: import { describe, expect, test, vi } from "vitest";
2:
3: const { authorizeApproval } = require("../approval-authorizer.js");
4:
5: const issueTitle = "publish: getsentry/sentry-javascript@10.0.0";
6:
7: describe("authorizeApproval", () => {
8: test("allows the auto-approval bot for an allowlisted release without a target repository lookup", async () => {
9: const getPermission = vi.fn();
10:
11: await expect(
12: authorizeApproval({
13: actor: "getsantry[bot]",
14: issueTitle,
15: getPermission,
16: autoApprovedRepositories: new Set(["getsentry/sentry-javascript"]),
17: })
18: ).resolves.toEqual({
19: authorized: true,
20: repository: "getsentry/sentry-javascript",
21: });
22:
23: expect(getPermission).not.toHaveBeenCalled();
24: });
25:
26: test("rejects the auto-approval bot ...
The existing validator already detects title and label-event drift, but the poller calls it too early. I’m adding a shared title parser and a final validation directly before ci-ready; failure will remove accepted and keep the release out of publication.
Success. Updated the following files: M src/modules/__tests__/details-from-context.js
apply_patch verification failed: Error: Failed to find expected lines in /home/byk/Code/getsentry/publish/src/modules/approval-authorizer.js:
const title = issueTitle.match(PUBLISH_TITLE_REGEX);
if (!title) {
<path>/home/byk/Code/getsentry/publish/src/modules/approval-authorizer.js</path>
<type>file</type>
<content>
1: const ALLOWED_ROLE_NAMES = new Set(["write", "maintain", "admin"]);
2: const AUTO_APPROVER = "getsantry[bot]";
3: const { PUBLISH_TITLE_REGEX } = require("./details-from-context.js");
4:
5: async function authorizeApproval({
6: actor,
7: issueTitle,
8: getPermission,
9: autoApprovedRepositories = new Set(),
10: }) {
11: const title = issueTitle.match(PUBLISH_TITLE_REGEX);
12:
13: if (!title) {
14: return { authorized: false, repository: null };
15: }
16:
17: const repository = `getsentry/${title.groups.repo}`;
18: const releasePath = `${repository}${title.groups.path || ""}`;
19:
20: if (actor === AUTO_APPROVER && autoApprovedRepositories.has(releasePath)) {
21: return { authorized: true, repository };
22: }
23:
24: if (actor === AUTO_APPROVER) {
25: return { authorized: false, repository };
26: }
27:
28: const { role_name } = ...
Success. Updated the following files: M src/modules/details-from-context.js M src/modules/approval-authorizer.js
Success. Updated the following files: M .github/workflows/ci-poller.yml
[
{
"content": "Inspect current approval workflow and final handoff gap",
"status": "completed",
"priority": "high"
},
{
"content": "Revalidate approval immediately before ci-ready",
"status": "in_progress",
"priority": "high"
},
{
"content": "Add regression coverage for approval drift",
"status": "pending",
"priority": "high"
},
{
"content": "Run full validation and substantive reviews",
"status": "pending",
"priority": "high"
}
]
yarn run v1.22.22 $ /home/byk/Code/getsentry/publish/node_modules/.bin/vitest run src/modules/__tests__/details-from-context.js src/modules/__tests__/approval-authorizer.js src/publish/__tests__/validate-approval-attestation.js [1m[46m RUN [49m[22m [36mv4.0.17 [39m[90m/home/byk/Code/getsentry/publish[39m [32m✓[39m src/modules/__tests__/details-from-context.js [2m([22m[2m5 tests[22m[2m)[22m[32m 8[2mms[22m[39m [32m✓[39m src/publish/__tests__/validate-approval-attestation.js [2m([22m[2m4 tests[22m[2m)[22m[32m 38[2mms[22m[39m [32m✓[39m src/modules/__tests__/approval-authorizer.js [2m([22m[2m14 tests[22m[2m)[22m[32m 17[2mms[22m[39m [2m Test Files [22m [1m[32m3 passed[39m[22m[90m (3)[39m [2m Tests [22m [1m[32m23 passed[39m[22m[90m (23)[39m [2m Start at [22m 18:30:50 [2m Duration [22m 371ms[2m (transform 121ms, setup 0ms, import 203ms, tests 63ms, environment 1ms)[22m Done in 0.86s. yarn run v1.22.22 $ prettier ...
No files found
The focused tests pass. Formatting exposed only the two files just changed, so I’m normalizing them, then I’ll run the complete test suite and inspect the final diff before the final reviews.
src/modules/details-from-context.js 97ms src/modules/__tests__/details-from-context.js 25ms yarn run v1.22.22 $ /home/byk/Code/getsentry/publish/node_modules/.bin/vitest run [1m[46m RUN [49m[22m [36mv4.0.17 [39m[90m/home/byk/Code/getsentry/publish[39m [32m✓[39m src/modules/__tests__/approval-attestation.js [2m([22m[2m3 tests[22m[2m)[22m[32m 19[2mms[22m[39m [32m✓[39m src/publish/__tests__/validate-approval-attestation.js [2m([22m[2m4 tests[22m[2m)[22m[32m 23[2mms[22m[39m [32m✓[39m src/modules/__tests__/approval-authorizer.js [2m([22m[2m14 tests[22m[2m)[22m[32m 17[2mms[22m[39m [32m✓[39m src/publish/__tests__/authorize-approval.js [2m([22m[2m3 tests[22m[2m)[22m[32m 15[2mms[22m[39m [32m✓[39m src/modules/__tests__/update-issue.js [2m([22m[2m5 tests[22m[2m)[22m[32m 25[2mms[22m[39m [32m✓[39m src/modules/__tests__/post-workflow-details.js [2m([22m[2m1 test[22m[2m)[22m[32m 13[2mms[22m[39m [32m✓[39m ...
[
{
"content": "Inspect current approval workflow and final handoff gap",
"status": "completed",
"priority": "high"
},
{
"content": "Revalidate approval immediately before ci-ready",
"status": "completed",
"priority": "high"
},
{
"content": "Add regression coverage for approval drift",
"status": "completed",
"priority": "high"
},
{
"content": "Run full validation and substantive reviews",
"status": "in_progress",
"priority": "high"
}
]
The complete suite now passes with 41 tests. I’m inspecting the actual workflow diff and running fresh correctness and security reviews against this final tree; both must return substantive reports before this is considered ready.
</task_result> </task>
The correctness reviewer again returned no report, so it does not clear the gate. I’m replacing it with a substantive security review and will run the repository lint in parallel; the known unrelated workflow lint failure remains a separate concern.
</task_result> </task>
The security review found two valid races: unproven ci-ready labels can start publishing, and a re-approval can occur between the poller’s final validation and label transition. I’m binding the CI handoff to the same accepted event and requiring that proof before publication.
continue
<path>/home/byk/Code/getsentry/publish/.github/workflows/publish.yml</path>
<type>file</type>
<content>
1: name: Publish
2: on:
3: issues:
4: types: [labeled]
5:
6: concurrency:
7: # Use the issue title (e.g. "publish: getsentry/foo@1.2.3") so duplicate
8: # issues for the same repo@version share a concurrency group.
9: group: ${{ github.event.issue.title }}
10: cancel-in-progress: false
11:
12: permissions:
13: contents: read
14: issues: write
15: packages: write
16:
17: jobs:
18: # When accepted is added to a publish issue:
19: # - Add ci-pending (and remove ci-failed if retrying)
20: # - Enable the poller via CI_POLLER_HAS_PENDING=true
21: # - Comment on the issue
22: # - Trigger the poller immediately so we don't wait for the next cron tick
23: # The publish job below requires ci-ready, so it will not fire until the
24: # poller flips ci-pending → ci-ready (which also prevents publishing without
25: # CI verification in the ...
<path>/home/byk/Code/getsentry/publish/.github/workflows/ci-poller.yml</path>
<type>file</type>
<content>
60: # token for cross-repo API calls (installed on all getsentry repos).
61: GH_TOKEN: ${{ steps.token.outputs.token }}
62: RELEASE_TOKEN: ${{ steps.release-token.outputs.token }}
63: run: |
64: # Helper: gh api using release-bot token (for cross-repo calls).
65: # Uses a subshell so GH_TOKEN override doesn't leak to other calls.
66: # Captures stdout and exits non-zero on API failure (including 404s)
67: # so callers can reliably detect errors.
68: gh_api_release() {
69: local output
70: output=$(GH_TOKEN="$RELEASE_TOKEN" gh api "$@" 2>&1)
71: local exit_code=$? ...
The fix needs a second, distinct proof: CI completion must bind to the same current accepted event and title. The poller will create that proof before adding ci-ready, and publication will require both the approval proof and the CI-ready proof.
<path>/home/byk/Code/getsentry/publish/src/publish/validate-approval-attestation.js</path>
<type>file</type>
<content>
1: const {
2: currentAcceptedEvent,
3: hasApprovalAttestation,
4: } = require("../modules/approval-attestation.js");
5:
6: async function getGitHubResponse(path) {
7: return fetch(`https://api.github.com/${path}`, {
8: headers: {
9: Accept: "application/vnd.github+json",
10: Authorization: `Bearer ${process.env.APPROVAL_TOKEN}`,
11: "X-GitHub-Api-Version": "2026-03-10",
12: },
13: });
14: }
15:
16: async function getIssue({ repository, issueNumber }) {
17: const response = await getGitHubResponse(
18: `repos/${repository}/issues/${issueNumber}`
19: );
20:
21: if (!response.ok) {
22: throw new Error(
23: `Could not retrieve issue #${issueNumber}: GitHub returned ${response.status}`
24: );
25: }
26:
27: return response.json();
28: }
29:
30: async function getAllPages({ repository, issueNumber, resource ...
<path>/home/byk/Code/getsentry/publish/src/modules/approval-attestation.js</path>
<type>file</type>
<content>
1: const ATTESTATION_PREFIX = "<!-- publish-approval ";
2: const ATTESTATION_SUFFIX = " -->";
3:
4: function createApprovalAttestation({ actor, eventId, title }) {
5: const value = Buffer.from(
6: JSON.stringify({ actor, eventId: String(eventId), title })
7: ).toString("base64url");
8:
9: return `${ATTESTATION_PREFIX}${value}${ATTESTATION_SUFFIX}`;
10: }
11:
12: function parseApprovalAttestation(body) {
13: const start = body.indexOf(ATTESTATION_PREFIX);
14:
15: if (start === -1) {
16: return null;
17: }
18:
19: const end = body.indexOf(ATTESTATION_SUFFIX, start);
20:
21: if (end === -1) {
22: return null;
23: }
24:
25: try {
26: const value = JSON.parse(
27: Buffer.from(
28: body.slice(start + ATTESTATION_PREFIX.length, end),
29: "base64url"
30: ).toString("utf8")
31: );
32:
33: if (
34: ...
Success. Updated the following files: M src/modules/approval-attestation.js
<path>/home/byk/Code/getsentry/publish/src/modules/approval-attestation.js</path>
<type>file</type>
<content>
1: const APPROVAL_ATTESTATION_PREFIX = "<!-- publish-approval ";
2: const CI_READY_ATTESTATION_PREFIX = "<!-- publish-ci-ready ";
3: const ATTESTATION_SUFFIX = " -->";
4:
5: function createAttestation(prefix, value) {
6: return `${prefix}${Buffer.from(JSON.stringify(value)).toString("base64url")}${ATTESTATION_SUFFIX}`;
7: }
8:
9: function createApprovalAttestation({ actor, eventId, title }) {
10: return createAttestation(APPROVAL_ATTESTATION_PREFIX, {
11: actor,
12: eventId: String(eventId),
13: title,
14: });
15: }
16:
17: function createCiReadyAttestation({ acceptedEvent, ciReadyEvent, title }) {
18: return createAttestation(CI_READY_ATTESTATION_PREFIX, {
19: acceptedActor: acceptedEvent.actor,
20: acceptedEventId: String(acceptedEvent.eventId),
21: ciReadyActor: ciReadyEvent.actor,
22: ciReadyEventId: String(ciReadyEvent.eventId),
23: ...
<path>/home/byk/Code/getsentry/publish/src/modules/__tests__/approval-attestation.js</path>
<type>file</type>
<content>
1: import { describe, expect, test } from "vitest";
2:
3: const {
4: createApprovalAttestation,
5: currentAcceptedEvent,
6: hasApprovalAttestation,
7: parseApprovalAttestation,
8: } = require("../approval-attestation.js");
9:
10: describe("approval attestations", () => {
11: test("matches the latest accepted event to a trusted attestation", () => {
12: const attestation = createApprovalAttestation({
13: actor: "contractor",
14: eventId: "200",
15: title: "publish: getsentry/relay/py@1.2.3",
16: });
17: const event = currentAcceptedEvent([
18: {
19: actor: { login: "contractor" },
20: event: "labeled",
21: id: "100",
22: label: { name: "accepted" },
23: },
24: {
25: actor: { login: "contractor" },
26: event: "labeled",
27: id: "200",
28: label: { ...
Success. Updated the following files: M src/modules/approval-attestation.js
The first attestation only proves authorization. I’m adding an independent CI-pass attestation, tied to that exact approval event and title, so publication rejects both manual ci-ready labels and approvals that change during the handoff.
Success. Updated the following files: M src/modules/__tests__/approval-attestation.js
Success. Updated the following files: A src/publish/record-ci-ready-attestation.js M src/publish/validate-approval-attestation.js
<path>/home/byk/Code/getsentry/publish/.github/workflows/ci-poller.yml</path> <type>file</type> <content> 1: name: CI Status Poller 2: 3: on: 4: schedule: 5: - cron: "*/5 * * * *" 6: workflow_dispatch: 7: inputs: 8: attempt: 9: description: Self-dispatch attempt counter (internal use) 10: required: false 11: default: "0" 12: 13: permissions: 14: contents: read 15: issues: read 16: 17: jobs: 18: check-ci: 19: runs-on: ubuntu-latest 20: environment: production 21: # Skip entirely (no runner provisioned) when there's nothing to check. 22: # Set to "true" by ci-pending.yml, reset to "false" here when done. 23: # Always allow workflow_dispatch for manual recovery. 24: if: vars.CI_POLLER_HAS_PENDING == 'true' || github.event_name == 'workflow_dispatch' 25: concurrency: 26: group: ci-status-poller 27: cancel-in-progress: false 28: steps: 29: - name: Get publish code 30: uses: ...
I’m completing the CI-pass proof now: the poller will post it only after its final validation and before adding ci-ready; the publisher will require it alongside the approval attestation.
continue
I’ll inspect the current attestation modules and workflow handoff, repair the incomplete CI-ready proof, then run focused and full validation.
[
{
"content": "Inspect current CI-ready attestation implementation and workflow handoff",
"status": "in_progress",
"priority": "high"
},
{
"content": "Complete CI-ready proof recording and publication validation",
"status": "pending",
"priority": "high"
},
{
"content": "Add regression coverage for attestation parsing and CI-ready proof",
"status": "pending",
"priority": "high"
},
{
"content": "Run focused and full validation",
"status": "pending",
"priority": "high"
}
]
<path>/home/byk/Code/getsentry/publish/src/publish/validate-approval-attestation.js</path>
<type>file</type>
<content>
1: const {
2: currentAcceptedEvent,
3: currentCiReadyEvent,
4: hasApprovalAttestation,
5: hasCiReadyAttestation,
6: } = require("../modules/approval-attestation.js");
7:
8: async function getGitHubResponse(path) {
9: return fetch(`https://api.github.com/${path}`, {
10: headers: {
11: Accept: "application/vnd.github+json",
12: Authorization: `Bearer ${process.env.APPROVAL_TOKEN}`,
13: "X-GitHub-Api-Version": "2026-03-10",
14: },
15: });
16: }
17:
18: async function getIssue({ repository, issueNumber }) {
19: const response = await getGitHubResponse(
20: `repos/${repository}/issues/${issueNumber}`
21: );
22:
23: if (!response.ok) {
24: throw new Error(
25: `Could not retrieve issue #${issueNumber}: GitHub returned ${response.status}`
26: );
27: }
28:
29: return response.json();
30: }
31:
32: async ...
<path>/home/byk/Code/getsentry/publish/.github/workflows/ci-poller.yml</path> <type>file</type> <content> 70: output=$(GH_TOKEN="$RELEASE_TOKEN" gh api "$@" 2>&1) 71: local exit_code=$? 72: if [[ $exit_code -ne 0 ]]; then 73: echo "::warning::gh api failed: $output" >&2 74: return $exit_code 75: fi 76: printf '%s' "$output" 77: } 78: # Only check issues that have BOTH ci-pending AND accepted labels. 79: # This avoids polling for abandoned releases that nobody approved. 80: issues=$(gh issue list -R "$GITHUB_REPOSITORY" \ 81: --state open \ 82: --label ci-pending \ 83: --label accepted \ 84: --limit 200 \ 85: --json number,title,labels,body) 86: 87: count=$(echo "$issues" | jq length) 88: if [[ "$count" == "0" ]]; then 89: echo "No ci-pending + accepted issues ...
<path>/home/byk/Code/getsentry/publish/.github/workflows/publish.yml</path>
<type>file</type>
<content>
1: name: Publish
2: on:
3: issues:
4: types: [labeled]
5:
6: concurrency:
7: # Use the issue title (e.g. "publish: getsentry/foo@1.2.3") so duplicate
8: # issues for the same repo@version share a concurrency group.
9: group: ${{ github.event.issue.title }}
10: cancel-in-progress: false
11:
12: permissions:
13: contents: read
14: issues: write
15: packages: write
16:
17: jobs:
18: # When accepted is added to a publish issue:
19: # - Add ci-pending (and remove ci-failed if retrying)
20: # - Enable the poller via CI_POLLER_HAS_PENDING=true
21: # - Comment on the issue
22: # - Trigger the poller immediately so we don't wait for the next cron tick
23: # The publish job below requires ci-ready, so it will not fire until the
24: # poller flips ci-pending → ci-ready (which also prevents publishing without
25: # CI verification in the ...
<path>/home/byk/Code/getsentry/publish/src/modules/details-from-context.js</path> <type>file</type> <content> 1: /** 2: * Matches the entire "Targets" section of a github publish issue body. 3: */ 4: const TARGETS_SECTION_PARSER_REGEX = 5: /^(?!### Targets$\s)(?: *- \[[ xX]\] \S+\s*$(?:\r?\n)?)+/m; 6: 7: /** 8: * Matches all targets of a github publish issue body in a section that was already matched and extracted with `TARGETS_PARSER_REGEX`. 9: * The "id" of the targets is captured within a capture group. 10: */ 11: const TARGETS_PARSER_REGEX = /^\s*- \[[ x]\] (\S+)/gim; 12: 13: /** 14: * Matches checked targets of a github publish issue body in a section that was already matched and extracted with `TARGETS_PARSER_REGEX`. 15: * The "id" of the targets is captured within a capture group. 16: */ 17: const CHECKED_TARGETS_PARSER_REGEX = /^\s*- \[x\] (\S+)/gim; 18: const PUBLISH_TITLE_REGEX = 19: /^publish: ...
<path>/home/byk/Code/getsentry/publish/src/publish/__tests__/validate-approval-attestation.js</path>
<type>file</type>
<content>
1: import { afterEach, describe, expect, test, vi } from "vitest";
2:
3: const {
4: validateApprovalAttestation,
5: } = require("../validate-approval-attestation.js");
6: const {
7: createApprovalAttestation,
8: } = require("../../modules/approval-attestation.js");
9:
10: afterEach(() => {
11: vi.restoreAllMocks();
12: });
13:
14: function jsonResponse(json) {
15: return { ok: true, json: vi.fn().mockResolvedValue(json) };
16: }
17:
18: describe("validateApprovalAttestation", () => {
19: test("accepts the current accepted event and its trusted attestation", async () => {
20: const title = "publish: getsentry/sentry-javascript@10.0.0";
21: const attestation = createApprovalAttestation({
22: actor: "contractor",
23: eventId: "100",
24: title,
25: });
26: global.fetch = vi
27: .fn()
28: ...
The incomplete design could not bind an event that had not happened. I’m changing it to attest the exact internal-app identity before the label write, then require the live ci-ready event to have that identity and a proof bound to the current approval.
Success. Updated the following files: M src/modules/approval-attestation.js M src/publish/record-ci-ready-attestation.js M src/publish/validate-approval-attestation.js M src/modules/__tests__/approval-attestation.js
The CI-ready proof now names the exact app identity that will apply the label. I’m wiring its recording through the poller and requiring it in the publisher; the poller will recheck the approval again immediately before the label transition.
Success. Updated the following files: M .github/workflows/ci-poller.yml M .github/workflows/publish.yml
yarn run v1.22.22 $ /home/byk/Code/getsentry/publish/node_modules/.bin/vitest run src/modules/__tests__/approval-attestation.js src/publish/__tests__/validate-approval-attestation.js [1m[46m RUN [49m[22m [36mv4.0.17 [39m[90m/home/byk/Code/getsentry/publish[39m [32m✓[39m src/modules/__tests__/approval-attestation.js [2m([22m[2m4 tests[22m[2m)[22m[32m 19[2mms[22m[39m [32m✓[39m src/publish/__tests__/validate-approval-attestation.js [2m([22m[2m4 tests[22m[2m)[22m[32m 20[2mms[22m[39m [2m Test Files [22m [1m[32m2 passed[39m[22m[90m (2)[39m [2m Tests [22m [1m[32m8 passed[39m[22m[90m (8)[39m [2m Start at [22m 10:00:38 [2m Duration [22m 259ms[2m (transform 64ms, setup 0ms, import 101ms, tests 39ms, environment 0ms)[22m Done in 0.84s.
Focused tests pass, but the CI poller’s workflow token only has issues: read; it cannot write the trusted proof comment. I’ll grant that token issue-write permission and add end-to-end validation tests for a genuine versus manually added ci-ready label.
[
{
"content": "Inspect current CI-ready attestation implementation and workflow handoff",
"status": "completed",
"priority": "high"
},
{
"content": "Complete CI-ready proof recording and publication validation",
"status": "in_progress",
"priority": "high"
},
{
"content": "Add regression coverage for attestation parsing and CI-ready proof",
"status": "pending",
"priority": "high"
},
{
"content": "Run focused and full validation",
"status": "pending",
"priority": "high"
}
]
Success. Updated the following files: M .github/workflows/ci-poller.yml M src/publish/__tests__/validate-approval-attestation.js
yarn run v1.22.22 $ /home/byk/Code/getsentry/publish/node_modules/.bin/vitest run src/modules/__tests__/approval-attestation.js src/publish/__tests__/validate-approval-attestation.js [1m[46m RUN [49m[22m [36mv4.0.17 [39m[90m/home/byk/Code/getsentry/publish[39m [32m✓[39m src/modules/__tests__/approval-attestation.js [2m([22m[2m4 tests[22m[2m)[22m[32m 19[2mms[22m[39m [32m✓[39m src/publish/__tests__/validate-approval-attestation.js [2m([22m[2m6 tests[22m[2m)[22m[32m 22[2mms[22m[39m [2m Test Files [22m [1m[32m2 passed[39m[22m[90m (2)[39m [2m Tests [22m [1m[32m10 passed[39m[22m[90m (10)[39m [2m Start at [22m 10:03:50 [2m Duration [22m 312ms[2m (transform 61ms, setup 0ms, import 108ms, tests 41ms, environment 0ms)[22m Done in 0.79s. yarn run v1.22.22 $ prettier --write src --write src/modules/approval-attestation.js src/modules/__tests__/approval-attestation.js src/publish/record-ci-ready-attestation.js ...
The proof validates both the current approval and the identity of the app that added ci-ready. I’m adding direct unit coverage for the proof recorder, including approval drift immediately before the transition.
Success. Updated the following files: A src/publish/__tests__/record-ci-ready-attestation.js
yarn run v1.22.22 $ /home/byk/Code/getsentry/publish/node_modules/.bin/vitest run src/modules/__tests__/approval-attestation.js src/publish/__tests__/validate-approval-attestation.js src/publish/__tests__/record-ci-ready-attestation.js [1m[46m RUN [49m[22m [36mv4.0.17 [39m[90m/home/byk/Code/getsentry/publish[39m [32m✓[39m src/modules/__tests__/approval-attestation.js [2m([22m[2m4 tests[22m[2m)[22m[32m 20[2mms[22m[39m [32m✓[39m src/publish/__tests__/validate-approval-attestation.js [2m([22m[2m6 tests[22m[2m)[22m[32m 41[2mms[22m[39m [32m✓[39m src/publish/__tests__/record-ci-ready-attestation.js [2m([22m[2m2 tests[22m[2m)[22m[32m 13[2mms[22m[39m [2m Test Files [22m [1m[32m3 passed[39m[22m[90m (3)[39m [2m Tests [22m [1m[32m12 passed[39m[22m[90m (12)[39m [2m Start at [22m 10:08:08 [2m Duration [22m 379ms[2m (transform 77ms, setup 0ms, import 202ms, tests 74ms, environment 1ms)[22m Done in 0.88s.
<path>/home/byk/Code/getsentry/publish/src/modules/approval-attestation.js</path>
<type>file</type>
<content>
1: const APPROVAL_ATTESTATION_PREFIX = "<!-- publish-approval ";
2: const CI_READY_ATTESTATION_PREFIX = "<!-- publish-ci-ready ";
3: const ATTESTATION_SUFFIX = " -->";
4:
5: function createAttestation(prefix, value) {
6: return `${prefix}${Buffer.from(JSON.stringify(value)).toString(
7: "base64url"
8: )}${ATTESTATION_SUFFIX}`;
9: }
10:
11: function createApprovalAttestation({ actor, eventId, title }) {
12: return createAttestation(APPROVAL_ATTESTATION_PREFIX, {
13: actor,
14: eventId: String(eventId),
15: title,
16: });
17: }
18:
19: function createCiReadyAttestation({ acceptedEvent, ciReadyActor, title }) {
20: return createAttestation(CI_READY_ATTESTATION_PREFIX, {
21: acceptedActor: acceptedEvent.actor,
22: acceptedEventId: String(acceptedEvent.eventId),
23: ciReadyActor,
24: title,
25: });
26: }
27:
28: function ...
<path>/home/byk/Code/getsentry/publish/src/publish/validate-approval-attestation.js</path>
<type>file</type>
<content>
1: const {
2: currentAcceptedEvent,
3: currentCiReadyEvent,
4: hasApprovalAttestation,
5: hasCiReadyAttestation,
6: } = require("../modules/approval-attestation.js");
7:
8: async function getGitHubResponse(path) {
9: return fetch(`https://api.github.com/${path}`, {
10: headers: {
11: Accept: "application/vnd.github+json",
12: Authorization: `Bearer ${process.env.APPROVAL_TOKEN}`,
13: "X-GitHub-Api-Version": "2026-03-10",
14: },
15: });
16: }
17:
18: async function getIssue({ repository, issueNumber }) {
19: const response = await getGitHubResponse(
20: `repos/${repository}/issues/${issueNumber}`
21: );
22:
23: if (!response.ok) {
24: throw new Error(
25: `Could not retrieve issue #${issueNumber}: GitHub returned ${response.status}`
26: );
27: }
28:
29: return response.json();
30: }
31:
32: async ...
I’m reviewing the final poller handoff and recorder wiring for token identity and race safety before running the complete suite.
<path>/home/byk/Code/getsentry/publish/src/publish/record-ci-ready-attestation.js</path>
<type>file</type>
<content>
1: const fs = require("fs");
2: const {
3: createCiReadyAttestation,
4: currentAcceptedEvent,
5: } = require("../modules/approval-attestation.js");
6: const {
7: getAllPages,
8: getGitHubResponse,
9: getIssue,
10: } = require("./validate-approval-attestation.js");
11:
12: async function getAuthenticatedLogin() {
13: const response = await getGitHubResponse("user");
14:
15: if (!response.ok) {
16: throw new Error(
17: `Could not retrieve the authenticated user: GitHub returned ${response.status}`
18: );
19: }
20:
21: const { login } = await response.json();
22:
23: if (typeof login !== "string") {
24: throw new Error("GitHub returned no authenticated user login");
25: }
26:
27: return login;
28: }
29:
30: async function recordCiReadyAttestation({
31: getAuthenticatedLogin,
32: getIssue,
33: getIssueEvents,
34: ...
Found 3 matches /home/byk/Code/getsentry/publish/.github/workflows/ci-poller.yml: Line 260: node src/publish/record-ci-ready-attestation.js; then Line 274: --body "$ci_ready_attestation"; then Line 301: echo " CI passed! Adding ci-ready label."
<path>/home/byk/Code/getsentry/publish/.github/workflows/ci-poller.yml</path>
<type>file</type>
<content>
205: # timed_out, action_required, stale, startup_failure).
206: # "success", "neutral", and "skipped" are considered passing.
207: unsuccessful_checks=$(echo "$all_checks" | jq -s '[.[] | select(.status == "completed" and .conclusion != "success" and .conclusion != "neutral" and .conclusion != "skipped")] | length')
208:
209: echo " commit_status=${commit_status} (${total_statuses} statuses) pending=${pending_checks} unsuccessful=${unsuccessful_checks} total=${total_checks}"
210:
211: # Require at least one check run or one commit status to exist —
212: # otherwise CI hasn't started yet and all counts would be 0.
213: if [[ "$total_checks" == "0" && "$total_statuses" == "0" ]]; then
214: echo " No check runs or commit statuses found — CI may not have started yet."
215: ...
The poller safely binds the ci-ready actor, but its source "$attestation_output" treats an issue comment as shell code. I’m replacing that with a literal read, then I’ll run the full suite and lint.
Success. Updated the following files: M .github/workflows/ci-poller.yml
I’m completing the final handoff checks now: replace the fragile shell-pattern guard, verify the tokens used to record and apply ci-ready match, then run full validation.
Success. Updated the following files: M .github/workflows/ci-poller.yml
I’m verifying the poller’s actual token flow and the final proof-to-label transition, then I’ll correct any mismatch before full validation.
<path>/home/byk/Code/getsentry/publish/.github/workflows/ci-poller.yml</path> <type>file</type> <content> 1: name: CI Status Poller 2: 3: on: 4: schedule: 5: - cron: "*/5 * * * *" 6: workflow_dispatch: 7: inputs: 8: attempt: 9: description: Self-dispatch attempt counter (internal use) 10: required: false 11: default: "0" 12: 13: permissions: 14: contents: read 15: issues: write 16: 17: jobs: 18: check-ci: 19: runs-on: ubuntu-latest 20: environment: production 21: # Skip entirely (no runner provisioned) when there's nothing to check. 22: # Set to "true" by ci-pending.yml, reset to "false" here when done. 23: # Always allow workflow_dispatch for manual recovery. 24: if: vars.CI_POLLER_HAS_PENDING == 'true' || github.event_name == 'workflow_dispatch' 25: concurrency: 26: group: ci-status-poller 27: cancel-in-progress: false 28: steps: 29: - name: Get publish code 30: uses: ...
The recorded proof is now read as data, never executed. I’m running the complete test suite and inspecting the final diff for workflow or security regressions.
yarn run v1.22.22 $ vitest run [1m[46m RUN [49m[22m [36mv4.0.17 [39m[90m/home/byk/Code/getsentry/publish[39m [32m✓[39m src/modules/__tests__/post-workflow-details.js [2m([22m[2m1 test[22m[2m)[22m[32m 26[2mms[22m[39m [32m✓[39m src/publish/__tests__/validate-approval-attestation.js [2m([22m[2m6 tests[22m[2m)[22m[32m 53[2mms[22m[39m [32m✓[39m src/publish/__tests__/authorize-approval.js [2m([22m[2m3 tests[22m[2m)[22m[32m 25[2mms[22m[39m [32m✓[39m src/modules/__tests__/approval-attestation.js [2m([22m[2m4 tests[22m[2m)[22m[32m 20[2mms[22m[39m [32m✓[39m src/publish/__tests__/record-ci-ready-attestation.js [2m([22m[2m2 tests[22m[2m)[22m[32m 8[2mms[22m[39m [32m✓[39m src/modules/__tests__/approval-authorizer.js [2m([22m[2m14 tests[22m[2m)[22m[32m 32[2mms[22m[39m [32m✓[39m src/modules/__tests__/process-end-state.js [2m([22m[2m4 tests[22m[2m)[22m[32m 48[2mms[22m[39m [32m✓[39m ...
All 46 tests pass. I’m checking the complete diff and repository validation now; any remaining failure must distinguish gate changes from the known unrelated workflow lint violation.
M .github/workflows/ci-poller.yml M .github/workflows/publish.yml M AGENTS.md M src/libs/__tests__/github.js M src/libs/github.js M src/modules/__tests__/details-from-context.js M src/modules/details-from-context.js M src/modules/post-workflow-details.js M src/modules/process-end-state.js M src/modules/update-issue.js M src/publish/inputs.js M src/publish/post-result.js M src/publish/post-workflow-details.js M src/publish/update-issue.js ?? .lore.md ?? src/modules/__tests__/approval-attestation.js ?? src/modules/__tests__/approval-authorizer.js ?? src/modules/approval-attestation.js ?? src/modules/approval-authorizer.js ?? src/publish/__tests__/ ?? src/publish/authorize-approval.js ?? src/publish/record-ci-ready-attestation.js ?? src/publish/validate-approval-attestation.js .github/workflows/ci-poller.yml | 117 ++++++++++++++++++++++++-- .github/workflows/publish.yml | 97 +++++++++++++++++++++ AGENTS.md ...
yarn run v1.22.22 $ eslint src .github --ignore-pattern '!.github' /home/byk/Code/getsentry/publish/.github/workflows/cocoapods-keepalive.yml 1:7 error Must use plain style scalar yml/plain-scalar ✖ 1 problem (1 error, 0 warnings) 1 error and 0 warnings potentially fixable with the `--fix` option. info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command. error Command failed with exit code 1.
Repository-wide lint remains blocked by the pre-existing cocoapods-keepalive.yml scalar error. I’ll run lint and formatting on the changed JavaScript and workflows separately, then inspect the attestation diff for final defects.
yarn run v1.22.22 $ /home/byk/Code/getsentry/publish/node_modules/.bin/eslint src Done in 0.81s. yarn run v1.22.22 $ prettier --write src --check .github/workflows/ci-poller.yml .github/workflows/publish.yml src/modules/approval-attestation.js src/modules/approval-authorizer.js src/modules/details-from-context.js src/modules/__tests__/approval-attestation.js src/modules/__tests__/approval-authorizer.js src/modules/__tests__/details-from-context.js src/publish/authorize-approval.js src/publish/record-ci-ready-attestation.js src/publish/validate-approval-attestation.js src/publish/__tests__/authorize-approval.js src/publish/__tests__/record-ci-ready-attestation.js src/publish/__tests__/validate-approval-attestation.js Checking formatting... All matched files use Prettier code style! Done in 0.91s.
The changed JavaScript and workflows are lint-clean and formatted. I’m performing a final adversarial review of the two attestations, their event order, and cleanup paths before declaring the authorization gate ready.
[
{
"content": "Inspect current CI-ready attestation implementation and workflow handoff",
"status": "completed",
"priority": "high"
},
{
"content": "Complete CI-ready proof recording and publication validation",
"status": "completed",
"priority": "high"
},
{
"content": "Add regression coverage for attestation parsing and CI-ready proof",
"status": "completed",
"priority": "high"
},
{
"content": "Run focused and full validation",
"status": "completed",
"priority": "high"
},
{
"content": "Perform final adversarial review of approval and CI-ready attestations",
"status": "in_progress",
"priority": "high"
}
]
<path>/home/byk/Code/getsentry/publish/.github/workflows/publish.yml</path>
<type>file</type>
<content>
1: name: Publish
2: on:
3: issues:
4: types: [labeled]
5:
6: concurrency:
7: # Use the issue title (e.g. "publish: getsentry/foo@1.2.3") so duplicate
8: # issues for the same repo@version share a concurrency group.
9: group: ${{ github.event.issue.title }}
10: cancel-in-progress: false
11:
12: permissions:
13: contents: read
14: issues: write
15: packages: write
16:
17: jobs:
18: # When accepted is added to a publish issue:
19: # - Add ci-pending (and remove ci-failed if retrying)
20: # - Enable the poller via CI_POLLER_HAS_PENDING=true
21: # - Comment on the issue
22: # - Trigger the poller immediately so we don't wait for the next cron tick
23: # The publish job below requires ci-ready, so it will not fire until the
24: # poller flips ci-pending → ci-ready (which also prevents publishing without
25: # CI verification in the ...
diff --git a/.github/workflows/ci-poller.yml b/.github/workflows/ci-poller.yml
index a3db430..9cd4246 100644
--- a/.github/workflows/ci-poller.yml
+++ b/.github/workflows/ci-poller.yml
@@ -12,7 +12,7 @@ on:
permissions:
contents: read
- issues: read
+ issues: write
jobs:
check-ci:
@@ -26,6 +26,11 @@ jobs:
group: ci-status-poller
cancel-in-progress: false
steps:
+ - name: Get publish code
+ uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
# sentry-internal-app token for label changes on this repo.
# This token is what triggers publish.yml downstream — see
# https://github.com/getsentry/publish for why GITHUB_TOKEN can't.
@@ -92,12 +97,32 @@ jobs:
title=$(echo "$issue" | jq -r '.title')
body=$(echo "$issue" | jq -r '.body')
- # Parse repo and version from title: "publish: owner/repo[/path]@version"
- # Only take owner/repo (first two ...
The final check still accepted a fresh, separately attested re-approval during the same polling pass. I’m binding the poller to the accepted event it observed before CI checks, so any later re-approval stops the transition even if its own attestation appears quickly.
<path>/home/byk/Code/getsentry/publish/src/publish/__tests__/validate-approval-attestation.js</path>
<type>file</type>
<content>
1: import { afterEach, describe, expect, test, vi } from "vitest";
2:
3: const {
4: validateApprovalAttestation,
5: } = require("../validate-approval-attestation.js");
6: const {
7: createApprovalAttestation,
8: createCiReadyAttestation,
9: } = require("../../modules/approval-attestation.js");
10:
11: afterEach(() => {
12: vi.restoreAllMocks();
13: });
14:
15: function jsonResponse(json) {
16: return { ok: true, json: vi.fn().mockResolvedValue(json) };
17: }
18:
19: describe("validateApprovalAttestation", () => {
20: test("accepts the current accepted event and its trusted attestation", async () => {
21: const title = "publish: getsentry/sentry-javascript@10.0.0";
22: const attestation = createApprovalAttestation({
23: actor: "contractor",
24: eventId: "100",
25: title,
26: });
27: global.fetch = vi
28: ...
Success. Updated the following files: M src/publish/__tests__/validate-approval-attestation.js M src/publish/__tests__/record-ci-ready-attestation.js
yarn run v1.22.22
$ /home/byk/Code/getsentry/publish/node_modules/.bin/vitest run src/publish/__tests__/validate-approval-attestation.js src/publish/__tests__/record-ci-ready-attestation.js
[1m[46m RUN [49m[22m [36mv4.0.17 [39m[90m/home/byk/Code/getsentry/publish[39m
[31m❯[39m src/publish/__tests__/record-ci-ready-attestation.js [2m([22m[2m3 tests[22m[2m | [22m[31m1 failed[39m[2m)[22m[32m 28[2mms[22m[39m
[32m✓[39m binds the current approval to the app that will add ci-ready[32m 6[2mms[22m[39m
[32m✓[39m rejects an approval that changes before ci-ready is recorded[32m 2[2mms[22m[39m
[31m [31m×[31m rejects a re-approval after the event observed by the poller[39m[32m 14[2mms[22m[39m
[31m❯[39m src/publish/__tests__/validate-approval-attestation.js [2m([22m[2m7 tests[22m[2m | [22m[31m1 failed[39m[2m)[22m[32m 40[2mms[22m[39m
[32m✓[39m accepts the current accepted event and its trusted attestation[32m ...
The new regression tests fail against the current implementation, proving the re-approval race. I’m adding exact expected-event matching to validation and proof recording, then passing that bound event through the poller.
Success. Updated the following files: M src/publish/validate-approval-attestation.js M src/publish/record-ci-ready-attestation.js M .github/workflows/ci-poller.yml
The expected event still needs to flow into the scripts. I’m replacing the ad hoc inline fetch with a validated event output and making both attestation commands reject any mismatch.
<path>/home/byk/Code/getsentry/publish/src/publish/validate-approval-attestation.js</path>
<type>file</type>
<content>
1: const {
2: currentAcceptedEvent,
3: currentCiReadyEvent,
4: hasApprovalAttestation,
5: hasCiReadyAttestation,
6: } = require("../modules/approval-attestation.js");
7:
8: async function getGitHubResponse(path) {
9: return fetch(`https://api.github.com/${path}`, {
10: headers: {
11: Accept: "application/vnd.github+json",
12: Authorization: `Bearer ${process.env.APPROVAL_TOKEN}`,
13: "X-GitHub-Api-Version": "2026-03-10",
14: },
15: });
16: }
17:
18: async function getIssue({ repository, issueNumber }) {
19: const response = await getGitHubResponse(
20: `repos/${repository}/issues/${issueNumber}`
21: );
22:
23: if (!response.ok) {
24: throw new Error(
25: `Could not retrieve issue #${issueNumber}: GitHub returned ${response.status}`
26: );
27: }
28:
29: return response.json();
30: }
31:
32: async ...
continue
I’ll bind the earliest approval event in each poll cycle, validate it before CI work, and preserve that exact event through the final transition. This closes the re-approval race without changing manual dispatch behavior.
continue
I’ll finish the event-binding flow, add the missing script coverage, then run the full test, lint, and formatting checks before a final review.
[
{
"content": "Complete poller binding to its observed accepted event",
"status": "in_progress",
"priority": "high"
},
{
"content": "Add coverage for event-binding CLI behavior",
"status": "pending",
"priority": "high"
},
{
"content": "Run full validation and inspect final approval flow",
"status": "pending",
"priority": "high"
},
{
"content": "Obtain substantive correctness and security reviews",
"status": "pending",
"priority": "high"
}
]
<path>/home/byk/Code/getsentry/publish/src/publish/record-ci-ready-attestation.js</path>
<type>file</type>
<content>
1: const fs = require("fs");
2: const {
3: createCiReadyAttestation,
4: currentAcceptedEvent,
5: } = require("../modules/approval-attestation.js");
6: const {
7: getAllPages,
8: getGitHubResponse,
9: getIssue,
10: } = require("./validate-approval-attestation.js");
11:
12: async function getAuthenticatedLogin() {
13: const response = await getGitHubResponse("user");
14:
15: if (!response.ok) {
16: throw new Error(
17: `Could not retrieve the authenticated user: GitHub returned ${response.status}`
18: );
19: }
20:
21: const { login } = await response.json();
22:
23: if (typeof login !== "string") {
24: throw new Error("GitHub returned no authenticated user login");
25: }
26:
27: return login;
28: }
29:
30: async function recordCiReadyAttestation({
31: expectedAcceptedEvent,
32: getAuthenticatedLogin,
33: getIssue,
34: ...
<path>/home/byk/Code/getsentry/publish/.github/workflows/ci-poller.yml</path>
<type>file</type>
<content>
215: continue
216: fi
217:
218: total_checks=$(echo "$all_checks" | jq -s 'length')
219: pending_checks=$(echo "$all_checks" | jq -s '[.[] | select(.status != "completed")] | length')
220: # Count checks with non-successful conclusions (failure, cancelled,
221: # timed_out, action_required, stale, startup_failure).
222: # "success", "neutral", and "skipped" are considered passing.
223: unsuccessful_checks=$(echo "$all_checks" | jq -s '[.[] | select(.status == "completed" and .conclusion != "success" and .conclusion != "neutral" and .conclusion != "skipped")] | length')
224:
225: echo " commit_status=${commit_status} (${total_statuses} statuses) pending=${pending_checks} unsuccessful=${unsuccessful_checks} total=${total_checks}"
226:
227: # Require at least ...
<path>/home/byk/Code/getsentry/publish/src/publish/__tests__/record-ci-ready-attestation.js</path>
<type>file</type>
<content>
1: import { describe, expect, test, vi } from "vitest";
2:
3: const {
4: parseCiReadyAttestation,
5: } = require("../../modules/approval-attestation.js");
6: const {
7: recordCiReadyAttestation,
8: } = require("../record-ci-ready-attestation.js");
9:
10: describe("recordCiReadyAttestation", () => {
11: test("binds the current approval to the app that will add ci-ready", async () => {
12: const title = "publish: getsentry/sentry-javascript@10.0.0";
13:
14: await expect(
15: recordCiReadyAttestation({
16: getAuthenticatedLogin: vi
17: .fn()
18: .mockResolvedValue("sentry-internal-app[bot]"),
19: getIssue: vi.fn().mockResolvedValue({
20: labels: [{ name: "accepted" }],
21: title,
22: }),
23: getIssueEvents: vi.fn().mockResolvedValue([
24: {
25: ...
No files found
yarn run v1.22.22 $ /home/byk/Code/getsentry/publish/node_modules/.bin/vitest run src/publish/__tests__/validate-approval-attestation.js src/publish/__tests__/record-ci-ready-attestation.js [1m[46m RUN [49m[22m [36mv4.0.17 [39m[90m/home/byk/Code/getsentry/publish[39m [32m✓[39m src/publish/__tests__/record-ci-ready-attestation.js [2m([22m[2m3 tests[22m[2m)[22m[32m 9[2mms[22m[39m [32m✓[39m src/publish/__tests__/validate-approval-attestation.js [2m([22m[2m7 tests[22m[2m)[22m[32m 26[2mms[22m[39m [2m Test Files [22m [1m[32m2 passed[39m[22m[90m (2)[39m [2m Tests [22m [1m[32m10 passed[39m[22m[90m (10)[39m [2m Start at [22m 12:56:13 [2m Duration [22m 285ms[2m (transform 96ms, setup 0ms, import 136ms, tests 35ms, environment 0ms)[22m Done in 0.76s.
The focused regressions now pass. I found the initial snapshot was taken after the first validation, so I’m moving it before that validation and treating an event-read failure as an invalid approval with cleanup.
Success. Updated the following files: M .github/workflows/ci-poller.yml
I’m hardening the snapshot failure path so a GitHub event-read error cannot bypass rejection cleanup, and adding a small unit test for the snapshot helper.
Success. Updated the following files: M .github/workflows/ci-poller.yml M src/publish/current-accepted-event.js A src/publish/__tests__/current-accepted-event.js
yarn run v1.22.22 $ /home/byk/Code/getsentry/publish/node_modules/.bin/vitest run src/publish/__tests__/current-accepted-event.js src/publish/__tests__/validate-approval-attestation.js src/publish/__tests__/record-ci-ready-attestation.js [1m[46m RUN [49m[22m [36mv4.0.17 [39m[90m/home/byk/Code/getsentry/publish[39m [32m✓[39m src/publish/__tests__/current-accepted-event.js [2m([22m[2m2 tests[22m[2m)[22m[32m 29[2mms[22m[39m [32m✓[39m src/publish/__tests__/record-ci-ready-attestation.js [2m([22m[2m3 tests[22m[2m)[22m[32m 9[2mms[22m[39m [32m✓[39m src/publish/__tests__/validate-approval-attestation.js [2m([22m[2m7 tests[22m[2m)[22m[32m 28[2mms[22m[39m [2m Test Files [22m [1m[32m3 passed[39m[22m[90m (3)[39m [2m Tests [22m [1m[32m12 passed[39m[22m[90m (12)[39m [2m Start at [22m 13:00:34 [2m Duration [22m 399ms[2m (transform 107ms, setup 0ms, import 186ms, tests 66ms, environment 1ms)[22m Done in 0.89s. ...
<path>/home/byk/Code/getsentry/publish/src/publish/current-accepted-event.js</path>
<type>file</type>
<content>
1: const { currentAcceptedEvent } = require("../modules/approval-attestation.js");
2: const { getAllPages } = require("./validate-approval-attestation.js");
3:
4: async function getCurrentAcceptedEvent({
5: getIssueEvents,
6: issueNumber,
7: repository,
8: }) {
9: const event = currentAcceptedEvent(
10: await getIssueEvents({ repository, issueNumber })
11: );
12:
13: if (!event) {
14: throw new Error("The issue has no current accepted label event");
15: }
16:
17: return event;
18: }
19:
20: async function main() {
21: for (const name of [
22: "APPROVAL_TOKEN",
23: "APPROVAL_ISSUE_NUMBER",
24: "APPROVAL_ISSUE_REPOSITORY",
25: ]) {
26: if (!process.env[name]) {
27: throw new Error(`No "${name}" environment variable found`);
28: }
29: }
30:
31: const event = await getCurrentAcceptedEvent({
32: getIssueEvents: ({ ...
<path>/home/byk/Code/getsentry/publish/src/publish/__tests__/current-accepted-event.js</path>
<type>file</type>
<content>
1: import { describe, expect, test, vi } from "vitest";
2:
3: const { getCurrentAcceptedEvent } = require("../current-accepted-event.js");
4:
5: describe("getCurrentAcceptedEvent", () => {
6: test("returns the latest accepted-label event", async () => {
7: await expect(
8: getCurrentAcceptedEvent({
9: getIssueEvents: vi.fn().mockResolvedValue([
10: {
11: actor: { login: "contractor" },
12: event: "labeled",
13: id: "100",
14: label: { name: "accepted" },
15: },
16: {
17: actor: { login: "contractor" },
18: event: "labeled",
19: id: "200",
20: label: { name: "accepted" },
21: },
22: ]),
23: issueNumber: "123",
24: repository: "getsentry/publish",
25: })
26: ).resolves.toEqual({ ...
src/publish/current-accepted-event.js 86ms src/publish/__tests__/current-accepted-event.js 20ms
The event snapshot is now fail-closed and preserved through each poller check. I’m validating the complete tree and inspecting the remaining label-transition race before declaring the gate ready.
yarn run v1.22.22 $ /home/byk/Code/getsentry/publish/node_modules/.bin/vitest run [1m[46m RUN [49m[22m [36mv4.0.17 [39m[90m/home/byk/Code/getsentry/publish[39m [32m✓[39m src/modules/__tests__/approval-authorizer.js [2m([22m[2m14 tests[22m[2m)[22m[32m 20[2mms[22m[39m [32m✓[39m src/publish/__tests__/current-accepted-event.js [2m([22m[2m2 tests[22m[2m)[22m[32m 19[2mms[22m[39m [32m✓[39m src/publish/__tests__/validate-approval-attestation.js [2m([22m[2m7 tests[22m[2m)[22m[32m 24[2mms[22m[39m [32m✓[39m src/modules/__tests__/post-workflow-details.js [2m([22m[2m1 test[22m[2m)[22m[32m 13[2mms[22m[39m [32m✓[39m src/modules/__tests__/process-end-state.js [2m([22m[2m4 tests[22m[2m)[22m[32m 47[2mms[22m[39m [32m✓[39m src/modules/__tests__/approval-attestation.js [2m([22m[2m4 tests[22m[2m)[22m[32m 19[2mms[22m[39m [32m✓[39m src/publish/__tests__/authorize-approval.js [2m([22m[2m3 tests[22m[2m)[22m[32m ...
All 50 tests pass, changed JavaScript lints cleanly, Prettier passes, and git diff --check is clean. I’m running independent correctness and security reviews on the final authorization path; their reports will determine whether any further hardening is needed.
<task id="ses_fb25f14b5ffeOY3ZKCadaSpYkn" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
<task id="ses_fb25e4149ffe1nyk9U7RonoeIw" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
Final correctness and security reviews are running. I’ll process their reports before closing the implementation.
Validation is complete; final correctness and security reviews are in progress.
Targeted tests pass: 33/33 across the six approval-attestation/authorization test files.
src/modules/approval-attestation.js:11-25 signs only actor, accepted-event ID, and title. src/publish/authorize-approval.js:111-136 reads only the issue title; src/publish/validate-approval-attestation.js:63-99 validates only title, labels, events, and comments.
This leaves the issue body and dry-run label mutable after approval:
ci-poller.yml:149-156 extracts the CI SHA from the mutable body.src/modules/details-from-context.js:36-64 derives dry-run, merge target, and targets from mutable issue state.src/publish/inputs.js:6-9 later uses the ci-ready event snapshot, which can contain attacker-modified body/labels.A Publish-repo issue editor can change the checked SHA to a known-green commit, change targets or merge target, or remove dry-run after a valid approval. The poller then validates CI for the altered SHA and Craft publishes the altered request. Bind a canonical request digest—including relevant body-derived fields, dry-run state, and checked SHA—to both attestations and validate it at every transition. Tests cover changed titles (validate-approval-attestation.js:58-95) but not this mutation.
src/modules/approval-authorizer.js:1,28-34 authorizes any target-repository write, maintain, or admin collaborator. src/publish/authorize-approval.js:111-120 fetches no issue author, so it cannot reject an actor approving their own request.
A user with Publish issue-label rights and target-repository write access can create a release issue and apply accepted themselves. This violates the required second-person approval boundary. ...
The security review found a real request-integrity gap: approval currently binds only the title, not the body-derived release state. I’m binding the canonical request state across authorization, CI promotion, and publication; I’ll retain the established target-repository authorization policy and verify automated-approval provenance against the actual workflow.
acceptedpublish.yml runs setup and dependency installation before it produces steps.inputs.outputs.result (.github/workflows/publish.yml:221-233). If either step fails, the failure handler still runs (.github/workflows/publish.yml:383-388) but passes an empty PUBLISH_ARGS.
src/publish/update-issue.js:7 parses that value before calling updateIssue; it throws, so updateIssue never reaches its accepted removal at src/modules/update-issue.js:67-74. The issue retains both accepted and ci-ready. A rerun can then validate the old attestations and publish without a fresh acceptance.
This violates invariant 5. Add a regression for failures before Parse and set inputs, and make cleanup independent of parsed publish inputs.
Only write, maintain, and admin roles pass (src/modules/approval-authorizer.js:1,28-34). The permission lookup targets the repository parsed from the release title (src/modules/approval-authorizer.js:17-18,28-32).
The bot exception is exact: only getsantry[bot] and only an exact allowlisted release path pass (src/modules/approval-authorizer.js:2,20-25; src/publish/authorize-approval.js:8-15). Authorization failures remove accepted and stop the job (.github/workflows/publish.yml:93-111).
The poller snapshots the current accepted event before CI work (.github/workflows/ci-poller.yml:100-116) and checks the live title plus exact event ID before CI, after CI, before proof creation, and immediately before ci-ready (.github/workflows/ci-poller.yml:117-131,253-267,274-289,316-333).
The attestation binds actor, event ID, and title ...
<path>/home/byk/Code/getsentry/publish/src/modules/approval-attestation.js</path>
<type>file</type>
<content>
1: const APPROVAL_ATTESTATION_PREFIX = "<!-- publish-approval ";
2: const CI_READY_ATTESTATION_PREFIX = "<!-- publish-ci-ready ";
3: const ATTESTATION_SUFFIX = " -->";
4:
5: function createAttestation(prefix, value) {
6: return `${prefix}${Buffer.from(JSON.stringify(value)).toString(
7: "base64url"
8: )}${ATTESTATION_SUFFIX}`;
9: }
10:
11: function createApprovalAttestation({ actor, eventId, title }) {
12: return createAttestation(APPROVAL_ATTESTATION_PREFIX, {
13: actor,
14: eventId: String(eventId),
15: title,
16: });
17: }
18:
19: function createCiReadyAttestation({ acceptedEvent, ciReadyActor, title }) {
20: return createAttestation(CI_READY_ATTESTATION_PREFIX, {
21: acceptedActor: acceptedEvent.actor,
22: acceptedEventId: String(acceptedEvent.eventId),
23: ciReadyActor,
24: title,
25: });
26: }
27:
28: function ...
I’m mapping the mutable request fields and their consumers before changing the attestation format. The self-approval finding conflicts with the project’s explicit target-access policy, so I will not add a second-person restriction.
<path>/home/byk/Code/getsentry/publish/src/modules/details-from-context.js</path> <type>file</type> <content> 1: /** 2: * Matches the entire "Targets" section of a github publish issue body. 3: */ 4: const TARGETS_SECTION_PARSER_REGEX = 5: /^(?!### Targets$\s)(?: *- \[[ xX]\] \S+\s*$(?:\r?\n)?)+/m; 6: 7: /** 8: * Matches all targets of a github publish issue body in a section that was already matched and extracted with `TARGETS_PARSER_REGEX`. 9: * The "id" of the targets is captured within a capture group. 10: */ 11: const TARGETS_PARSER_REGEX = /^\s*- \[[ x]\] (\S+)/gim; 12: 13: /** 14: * Matches checked targets of a github publish issue body in a section that was already matched and extracted with `TARGETS_PARSER_REGEX`. 15: * The "id" of the targets is captured within a capture group. 16: */ 17: const CHECKED_TARGETS_PARSER_REGEX = /^\s*- \[x\] (\S+)/gim; 18: const PUBLISH_TITLE_REGEX = 19: /^publish: ...
<path>/home/byk/Code/getsentry/publish/src/publish/authorize-approval.js</path>
<type>file</type>
<content>
1: const fs = require("fs");
2: const {
3: createApprovalAttestation,
4: currentAcceptedEvent,
5: } = require("../modules/approval-attestation.js");
6: const { authorizeApproval } = require("../modules/approval-authorizer.js");
7:
8: function getAutoApprovedRepositories() {
9: return new Set(
10: fs
11: .readFileSync("auto-approve-repos.txt", "utf8")
12: .split(/\r?\n/)
13: .filter(Boolean)
14: );
15: }
16:
17: async function getPermission({ owner, repository, username }) {
18: const response = await getGitHubResponse(
19: `repos/${encodeURIComponent(owner)}/${encodeURIComponent(
20: repository
21: )}/collaborators/${encodeURIComponent(username)}/permission`
22: );
23:
24: if (!response.ok) {
25: throw new Error(
26: `Could not retrieve ${username}'s permission for ${owner}/${repository}: GitHub returned ...
The correctness review also found a concrete cleanup defect before input parsing. I’ll make failure cleanup remove accepted without depending on parsed publishing inputs, then add the request-state proofs and rerun the suite.
<path>/home/byk/Code/getsentry/publish/.github/workflows/publish.yml</path>
<type>file</type>
<content>
150: with:
151: client-id: ${{ vars.CI_POLLER_APP_CLIENT_ID }}
152: private-key: ${{ secrets.CI_POLLER_APP_PRIVATE_KEY }}
153:
154: - name: Enable cron poller
155: if: steps.poller-token.outcome == 'success'
156: env:
157: GH_TOKEN: ${{ steps.poller-token.outputs.token }}
158: run: |
159: gh variable set CI_POLLER_HAS_PENDING -R "$GITHUB_REPOSITORY" -b "true"
160:
161: # Trigger the CI poller immediately instead of waiting for the next cron tick.
162: # Uses the app token — GITHUB_TOKEN workflow_dispatch events are suppressed.
163: - name: Trigger CI poller
164: env:
165: GH_TOKEN: ${{ steps.token.outputs.token }}
166: run: |
167: gh workflow run ci-poller.yml -R "$GITHUB_REPOSITORY"
168:
169: publish:
170: runs-on: ubuntu-latest
171: ...
<path>/home/byk/Code/getsentry/publish/src/modules/__tests__/details-from-context.js</path>
<type>file</type>
<content>
1: import { test, expect } from "vitest";
2:
3: const {
4: detailsFromContext,
5: parsePublishTitle,
6: } = require("../details-from-context.js");
7:
8: const inputsArgs = {
9: context: {
10: repo: { owner: "getsentry", repo: "publish" },
11: payload: {
12: issue: {
13: number: "223",
14: title: "publish: getsentry/sentry@21.3.1",
15: body: `
16: Requested by: @BYK
17:
18: Merge target: custom-branch
19:
20: Quick links:
21: - [View changes](https://github.com/getsentry/sentry/compare/21.3.0...refs/heads/releases/21.3.1)
22: - [View check runs](https://github.com/getsentry/sentry/commit/7e5ca7ed5581552de066e2a8bc295b8306be38ac/checks/)
23:
24: Assign the **accepted** label to this issue to approve the release. ...
<path>/home/byk/Code/getsentry/publish/.github/workflows/auto-approve.yml</path>
<type>file</type>
<content>
1: name: auto-approve non-sdks
2: on:
3: issues:
4: types: [opened]
5:
6: permissions:
7: contents: read
8:
9: jobs:
10: auto-approve:
11: runs-on: ubuntu-latest
12: if: |
13: (github.actor == 'sentry-release-bot[bot]' || github.actor == 'getsantry[bot]') &&
14: startsWith(github.event.issue.title, 'publish: ')
15: steps:
16: - uses: actions/checkout@v4
17: with:
18: sparse-checkout: auto-approve-repos.txt
19: sparse-checkout-cone-mode: false
20: - name: Get auth token
21: id: token
22: uses: actions/create-github-app-token@v3
23: with:
24: client-id: ${{ vars.SENTRY_INTERNAL_APP_ID }}
25: private-key: ${{ secrets.SENTRY_INTERNAL_APP_PRIVATE_KEY }}
26: - name: Auto-approve if repo is in the list
27: env:
28: GH_TOKEN: ${{ ...
<path>/home/byk/Code/getsentry/publish/src/modules/__tests__/approval-attestation.js</path>
<type>file</type>
<content>
1: import { describe, expect, test } from "vitest";
2:
3: const {
4: createApprovalAttestation,
5: createCiReadyAttestation,
6: currentAcceptedEvent,
7: hasApprovalAttestation,
8: hasCiReadyAttestation,
9: parseApprovalAttestation,
10: } = require("../approval-attestation.js");
11:
12: describe("approval attestations", () => {
13: test("matches the latest accepted event to a trusted attestation", () => {
14: const attestation = createApprovalAttestation({
15: actor: "contractor",
16: eventId: "200",
17: title: "publish: getsentry/relay/py@1.2.3",
18: });
19: const event = currentAcceptedEvent([
20: {
21: actor: { login: "contractor" },
22: event: "labeled",
23: id: "100",
24: label: { name: "accepted" },
25: },
26: {
27: actor: { login: "contractor" },
28: ...
[
{
"content": "Inspect current approval workflow and attestation implementation",
"status": "in_progress",
"priority": "high"
},
{
"content": "Bind requested release state into approval and CI-ready attestations",
"status": "pending",
"priority": "high"
},
{
"content": "Harden publication failure cleanup before input parsing",
"status": "pending",
"priority": "high"
},
{
"content": "Add regressions and run full validation",
"status": "pending",
"priority": "high"
},
{
"content": "Process substantive final reviews",
"status": "pending",
"priority": "high"
}
]
<path>/home/byk/Code/getsentry/publish/src/modules/approval-attestation.js</path>
<type>file</type>
<content>
1: const APPROVAL_ATTESTATION_PREFIX = "<!-- publish-approval ";
2: const CI_READY_ATTESTATION_PREFIX = "<!-- publish-ci-ready ";
3: const ATTESTATION_SUFFIX = " -->";
4:
5: function createAttestation(prefix, value) {
6: return `${prefix}${Buffer.from(JSON.stringify(value)).toString(
7: "base64url"
8: )}${ATTESTATION_SUFFIX}`;
9: }
10:
11: function createApprovalAttestation({ actor, eventId, title }) {
12: return createAttestation(APPROVAL_ATTESTATION_PREFIX, {
13: actor,
14: eventId: String(eventId),
15: title,
16: });
17: }
18:
19: function createCiReadyAttestation({ acceptedEvent, ciReadyActor, title }) {
20: return createAttestation(CI_READY_ATTESTATION_PREFIX, {
21: acceptedActor: acceptedEvent.actor,
22: acceptedEventId: String(acceptedEvent.eventId),
23: ciReadyActor,
24: title,
25: });
26: }
27:
28: function ...
I’m completing the event-bound handoff now: I’ll verify the current scripts and workflow state, then close any remaining release-request and cleanup gaps with regression coverage.
[
{
"content": "Inspect current approval workflow and attestation implementation",
"status": "in_progress",
"priority": "high"
},
{
"content": "Bind requested release state into approval and CI-ready attestations",
"status": "pending",
"priority": "high"
},
{
"content": "Harden publication failure cleanup before input parsing",
"status": "pending",
"priority": "high"
},
{
"content": "Add regressions and run full validation",
"status": "pending",
"priority": "high"
},
{
"content": "Process substantive final reviews",
"status": "pending",
"priority": "high"
}
]
I’m checking the remaining publish inputs against what the approval proof actually binds. Any issue-controlled field that Craft consumes must be fixed before CI and publication, not just the title.
<path>/home/byk/Code/getsentry/publish/src/modules/details-from-context.js</path> <type>file</type> <content> 1: /** 2: * Matches the entire "Targets" section of a github publish issue body. 3: */ 4: const TARGETS_SECTION_PARSER_REGEX = 5: /^(?!### Targets$\s)(?: *- \[[ xX]\] \S+\s*$(?:\r?\n)?)+/m; 6: 7: /** 8: * Matches all targets of a github publish issue body in a section that was already matched and extracted with `TARGETS_PARSER_REGEX`. 9: * The "id" of the targets is captured within a capture group. 10: */ 11: const TARGETS_PARSER_REGEX = /^\s*- \[[ x]\] (\S+)/gim; 12: 13: /** 14: * Matches checked targets of a github publish issue body in a section that was already matched and extracted with `TARGETS_PARSER_REGEX`. 15: * The "id" of the targets is captured within a capture group. 16: */ 17: const CHECKED_TARGETS_PARSER_REGEX = /^\s*- \[x\] (\S+)/gim; 18: const PUBLISH_TITLE_REGEX = 19: /^publish: ...
Date: Aug 29, 2026 * 🔴 [requested-continuation] (12:45) User requested continuation of the implementation. * 🟡 (12:46) Assistant planned to finish the event-binding flow, add missing script coverage, then run full tests, lint, formatting, and a final review. * 🟢 (12:47) Task status: “Complete poller binding to its observed accepted event” was in progress; “Add coverage for event-binding CLI be…
Date: Aug 29, 2026 * 🟡 (12:17) Tool updated test files `src/publish/__tests__/validate-approval-attestation.js` and `src/publish/__tests__/record-ci-ready-attestation.js`. * 🟡 (12:22) Focused command `yarn vitest run src/publish/__tests__/validate-approval-attestation.js src/publish/__tests__/record-ci-ready-attestation.js` failed: 2 test files failed, with 8 passed and 2 failed tests; duration…
Date: Aug 29, 2026 * 🟡 (11:51) Full `yarn vitest run` succeeded: 10 test files passed and 46 tests passed. Test duration was 1.72s (transform 195ms, import 1.39s, tests 237ms); Yarn completed in 2.25s. * 🟡 (11:51) Passing Vitest suites were: `src/modules/__tests__/post-workflow-details.js` (1 test), `src/publish/__tests__/validate-approval-attestation.js` (6), `src/publish/__tests__/authorize-a…
Date: Aug 29, 2026 * 🟡 (11:02) Inspected final `src/publish/validate-approval-attestation.js` (140 lines). `validateApprovalAttestation()` concurrently fetches the issue, all issue events, and all comments; approval requires the fetched title to equal `issueTitle`, a live `accepted` label, a current accepted-label event, and `hasApprovalAttestation()`. With `requireCiReadyAttestation: true`, it …
Date: Aug 29, 2026 * 🟢 (09:50) User said “continue.” * 🟡 (09:50) Assistant planned to inspect the attestation modules/workflow handoff, repair incomplete CI-ready proof handling, and run focused plus full validation. * 🟡 (09:51) Work plan listed four high-priority items: 1. inspect current CI-ready attestation implementation and workflow handoff (in progress); 2. complete CI-ready proof record…
Date: Aug 28, 2026 * 🟡 (20:21) Inspected `src/modules/__tests__/approval-attestation.js` (90 lines). Existing Vitest tests import `createApprovalAttestation`, `currentAcceptedEvent`, `hasApprovalAttestation`, and `parseApprovalAttestation` from `../approval-attestation.js`; verify latest `accepted` event selection (`id: "200"` over `"100"`), numeric GitHub event ID normalization (`29503999078` →…
Date: Aug 28, 2026 * 🟡 (19:51) Background task “Review approval security” completed with an empty `task_result`; assistant nevertheless identified two security races: an unproven `ci-ready` label can initiate publishing, and re-approval can occur between the CI poller’s final validation and its label transition. * 🟡 (19:51) Assistant planned to bind the CI handoff to the same `accepted` event a…
Date: Aug 28, 2026 * 🟡 (18:03) Inspected `/home/byk/Code/getsentry/publish/src/modules/details-from-context.js` (66 lines). It defines `TARGETS_SECTION_PARSER_REGEX`, `TARGETS_PARSER_REGEX`, `CHECKED_TARGETS_PARSER_REGEX`, and `PUBLISH_TITLE_REGEX = /^publish: (?:getsentry\/)?(?<repo>[^/@]+)(?<path>\/[\w./-]+)?@(?<version>[\w.+-]+)$/`; `detailsFromContext({ context })` validate…
Date: Aug 28, 2026
Date: Aug 28, 2026 * 🟡 (17:39) Assistant stated it was reviewing authorization paths for races and privilege boundaries, would make remaining fail-closed fixes, and rerun the focused suite. * 🔴 (17:39) User provided `src/publish/authorize-approval.js` (157 lines). `getAutoApprovedRepositories()` reads `auto-approve-repos.txt` as UTF-8, splits on `/\r?\n/`, filters blank lines, and returns a `Se…
Date: Aug 27, 2026 * 🟡 (21:56) Background task `ses_fbb25722bffe6VcAZUCaO5Vva8` (“Adversarial workflow review”) completed but returned an empty `<task_result>` with no findings or report. * 🟡 (21:56) Assistant stated that the replacement reviewer returned no findings and therefore did not satisfy the review gate; it would inspect the final working tree and approval path directly, run the full v…
Date: Aug 27, 2026 * 🟡 (20:20) Background task `ses_fbb21c22effeAavAYTbRleeofj` (“Security gate review”) completed but returned an empty `<task_result>` with no findings or report. * 🟡 (20:20) Assistant stated that two security-review attempts produced no report and do not clear the approval gate; it is directly checking automated-approval provenance and workflow-token semantics while the corre…
Date: Aug 27, 2026 * 🟡 (20:16) Background task `ses_fbb300e3effeP4a5WW0BqPbjzV` (“Review approval gate security”) completed but returned an empty `<task_result>` with no findings or report. * 🟡 (20:17) Assistant stated that the security reviewer returned no report and therefore did not clear the release-approval gate; assistant said it would replace the review with one required to provide line-…
### Current State - Date: Aug 27, 2026. Active task: implement and harden the getsentry/publish release-approval authorization gate for issue #9355, after the user requested research first (13:46), implementation continuation (14:27, 14:55), and identified the critical invariant at 16:19: authorization “never binds that title or repository to later stages.” - Repository: `/home/byk/Code/getsentry…
Date: Aug 27, 2026 * 🟡 (20:13) Independent background correctness reviewer task `ses_fbb32b75affe6fDLmwvTL8iPpV` completed but returned an empty report; assistant deemed this insufficient for the merge gate. * 🟡 (20:13) Assistant decided to replace the empty correctness review with a substantive review while the independent security review continued. * 🟡 (20:14) Started replacement background …
Date: Aug 27, 2026 * 🟡 (17:08) Repository search returned approval-related files: `src/publish/validate-approval-attestation.js`, `src/publish/__tests__/authorize-approval.js`, `src/publish/authorize-approval.js`, `src/modules/approval-attestation.js`, `src/modules/approval-authorizer.js`, `src/modules/__tests__/approval-attestation.js`, and `src/modules/__tests__/approval-authorizer.js`. * 🟡 (…
Date: Aug 27, 2026 * 🟡 (17:06) `src/publish/__tests__/authorize-approval.js` imports `afterEach`, `describe`, `expect`, `test`, and `vi` from `vitest`, imports `fs`, and requires `getAutoApprovedRepositories` and `main` from `../authorize-approval.js`. * 🟡 (17:06) `src/publish/__tests__/authorize-approval.js` snapshots `originalEnvironment = { ...process.env }`; its `afterEach` restores `proces…
Date: Aug 27, 2026 * 🟡 (16:38) `src/modules/approval-attestation.js` initially defined `ATTESTATION_PREFIX = "<!-- publish-approval "` and `ATTESTATION_SUFFIX = " -->"`; `createApprovalAttestation({ actor, eventId, title })` base64url-encodes JSON `{ actor, eventId: String(eventId), title }` inside an HTML comment. * 🟡 (16:38) `src/modules/approval-attestation.js` initially defined `parseApprov…
Date: Aug 27, 2026 * 🟡 (16:38) Working tree status: modified `.github/workflows/publish.yml`, `AGENTS.md`, `src/libs/__tests__/github.js`, `src/libs/github.js`, `src/modules/details-from-context.js`, `src/modules/post-workflow-details.js`, `src/modules/process-end-state.js`, `src/modules/update-issue.js`, `src/publish/inputs.js`, `src/publish/post-result.js`, `src/publish/post-workflow-details.j…
Date: Aug 27, 2026 * 🟡 (16:19) Background security review `ses_fbc2027dbfferQZUN0ETLQ4agz` completed DO-NOT-MERGE. Critical: authorization reads the immutable `accepted`-label event title but only persists `authorized`; it never binds that title or repository to later stages. `.github/workflows/ci-poller.yml` and Publish then consume the mutable current issue title, enabling authorization for re…
Date: Aug 27, 2026 * 🟡 (15:55) Background correctness review `ses_fbc205116ffesmEG8SWfAbIqaj` completed with DO-NOT-MERGE findings. Critical: `.github/workflows/publish.yml:64` and `.github/workflows/ci-poller.yml:92` authorize against the issue-title snapshot at the `accepted` label event but the poller later reads the current title; an approver can approve repo A, change the title to repo B, a…
Date: Aug 27, 2026 * 🟡 (15:35) Background review “Review approval gate” completed with DO-NOT-MERGE findings: HIGH—`src/modules/approval-authorizer.js:5-24` lacked separation-of-duties enforcement, so a release requester with `write`, `maintain`, or `admin` target-repository access could self-apply `accepted`; the workflow never supplies requester identity, and no self-approval regression test e…
Date: Aug 27, 2026 * 🟡 (15:12) Code changes created `src/publish/authorize-approval.js`, modified `.github/workflows/publish.yml`, and added/modified authorization tests in `src/modules/__tests__/approval-authorizer.js` and `src/publish/__tests__/authorize-approval.js`. The workflow now calls the shared `approval-authorizer` so authorization behavior is testable outside GitHub Actions. * 🟡 (15:…
Date: Aug 27, 2026 * 🟡 (15:11) Tool output listed historical issue-event records with timestamps, actors, and issue numbers; visible entries included multiple `getsantry[bot]` events (e.g., issues `8090`, `8088`, `8086`, `8085`, `8083`, `8079`, `8010`, `7534`, `7430`) plus human actors such as `Flash0ver`, `JPeer264`, `cleptric`, `BYK`, `mujacica`, `joshuarli`, `rbro112`, `sentrivana`, `MathurAd…
Date: Aug 27, 2026 * 🟡 (15:07) Tool reported working-tree changes: modified `.github/workflows/publish.yml`; untracked `src/modules/__tests__/approval-authorizer.js` and `src/modules/approval-authorizer.js`. * 🟡 (15:07) Tool showed the approval-gate workflow diff: `Authorize approval` parses only titles matching `^publish: \(getsentry/[^/@]*\).*@[^@]*$`, treats an unparseable title as `authoriz…
Date: Aug 27, 2026 * 🟡 (14:46) Tool reported successful creation of `src/modules/approval-authorizer.js` and modification of `.github/workflows/publish.yml` for the release-approval authorization gate. * 🟡 (14:47) Assistant identified a sequencing defect in the initial workflow wiring: the unauthorized-rejection step referenced an app token before that token was created. It stated it would corr…
Date: Aug 27, 2026 * 🟡 (14:27) User asked to continue implementation of the release-approval authorization gate. * 🟡 (14:27) Assistant stated it would implement the authorization gate as a fail-closed workflow check, then run focused and full test suites. * 🔴 (14:29) User stated that the CI poller always adds the `ci-ready` label after checking CI, including when `ci-ready` was already present…
Date: Aug 27, 2026 * 🟡 (14:08) `/home/byk/Code/getsentry/security-as-code/rbac/env/prod-github/README.md` states GitHub teams/members are managed in `rbac/env/prod-github/team/`, repository access in `rbac/env/prod-github/repo`, and member syncing in `rbac/env/prod-github/team-members-syncing`; it links Notion instructions for managing GitHub teams/members, repository access, and maintainer tech…
Date: Aug 27, 2026 * 🟡 (14:05) GitHub API documentation investigation found the collaborator permission-level endpoint requires GitHub App repository permission `Metadata: read`; `Administration` is not required. Installation access to the target repository is required; IAT is accepted (`UAT, IAT`). Effective highest role reflects repository, team, organization, and enterprise grants rather than…
Date: Aug 27, 2026 * 🟡 (13:55) Repository inventory for `/home/byk/Code/getsentry/security-as-code/rbac/env/prod-github/` includes root Terraform files `moved.tf`, `.terraform.lock.hcl`, `_backend.tf`, `README.md`, executable/directory `sac-terraformer`; organization configuration `org/variables.yml`, `org/ruleset_warden-scan.tf`, `org/ruleset_secret-scanning.tf`, `org/ruleset_dependency-review.…
Date: Aug 27, 2026 * 🟡 (14:01) User supplied GitHub documentation on required GitHub App permissions: permissions define API resources an app may access; REST responses provide `X-Accepted-GitHub-Permissions` to indicate endpoint requirements; listed permissions apply to private resources, while some endpoints also support public resources without them; certain endpoints require multiple permiss…
Date: Aug 27, 2026 * 🟡 (14:00) `.github/workflows/test.yml` is named `Test`; it runs on pushes to `main` and on `pull_request`, grants `contents: read`, and defaults all `run` steps to Bash. * 🟡 (14:00) `test.yml` defines job `unit-test` (display name `unit tests`) on `ubuntu-latest`: checks out with `actions/checkout@v6`, sets up Node.js 24 with `actions/setup-node@v6`, caches `node_modules` v…
Date: Aug 27, 2026 * 🔴 (13:53) User stated that the private `security-as-code` repository should be locally accessible and may be checked out at `~/Code/getsentry/security-as-code`. * 🟡 (13:53) Assistant found the existing `getsentry/publish` release authorization model has no approver authorization check: adding or removing issue labels directly drives workflow execution. It stated it would ma…
Date: Aug 27, 2026 * 🟡 (13:46) [requested-research] User asked to investigate and implement getsentry/publish issue https://github.com/getsentry/publish/issues/9355, explicitly requiring thorough research and a feasibility study before implementation. * 🟡 (13:46) Assistant stated it would examine the issue, current publish flow, adjacent parsing/validation code, and test coverage, then assess s…