Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
ae0da31
fix(sisyphus): make GitLab CI work without glab CLI
deralaxo Mar 24, 2026
282e8ef
fix(sisyphus): fix GitLab CI templates and JOB-TOKEN auth
deralaxo Mar 24, 2026
d194060
fix(sisyphus): handle missing changelog files in release-pr
deralaxo Mar 25, 2026
9df7c43
feat(sisyphus): add setup hints to `actions init` for GitHub and GitLab
deralaxo Mar 25, 2026
18c42fe
fix(sisyphus): filter GitLab findPr to open MRs only
deralaxo Mar 25, 2026
7415169
fix(sisyphus): throw Error instead of Exit for release failures
deralaxo Mar 25, 2026
5af418a
fix(sisyphus): preserve API error details in release orchestrator
deralaxo Mar 26, 2026
b8f6119
fix(sisyphus): call ensureAvailable in createGitProvider
deralaxo Mar 26, 2026
14ef9bc
fix(sisyphus): URL-encode owner/repo in GitHub API paths
deralaxo Mar 26, 2026
c40289c
fix(sisyphus): replace shell redirect with quiet/nothrow in
deralaxo Mar 26, 2026
fde0161
fix(sisyphus): prevent commit analyzer from processing entire git
deralaxo Mar 26, 2026
90d8b5b
fix(sisyphus): use finally for branch restore in release-pr
deralaxo Mar 26, 2026
45a6a02
fix(sisyphus): use newVersion in fallback stone message
deralaxo Mar 26, 2026
c01184b
feat(core): add delete method to ConfigManager, remove currentRelease
deralaxo Mar 26, 2026
23f1ee8
perf(sisyphus): batch git calls in Commit.fetchFromRange
deralaxo Mar 26, 2026
3bb503e
fix(sisyphus): fall back to git diff-tree for squash merge file
deralaxo Mar 26, 2026
8a187d3
fix(sisyphus): use function replacers in changelog header templates
deralaxo Mar 26, 2026
eb202da
refactor(sisyphus): deduplicate URL parsing between GitRemoteParser and
deralaxo Mar 26, 2026
5bc1dc0
feat(sisyphus): add handle-push job for direct pushes to default branch
deralaxo Mar 27, 2026
e7a375b
fix(sisyphus): skip handle-push for GitHub PR merges via web-flow
deralaxo Mar 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/core/src/config-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ export class ConfigManager<T extends object> {
this.save();
}

public delete<K extends keyof T>(key: K): void {
delete this.config[key];
this.save();
}

public exists(): boolean {
return fs.existsSync(this.configPath);
}
Expand Down
15 changes: 14 additions & 1 deletion packages/sisyphus/src/commands/actions/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { dirname } from "node:path";
import { args, color, confirm, Exit, log, multiselect, note } from "@r5n/cli-core";
import { BaseCommand, type Ctx } from "../../base-command";
import { detectProvider } from "../../providers";
import { createCiGenerator, GitLabCiGenerator, WORKFLOW_CONFIGS, type WorkflowType } from "./CiGenerator";
import { createCiGenerator, GitHubCiGenerator, GitLabCiGenerator, WORKFLOW_CONFIGS, type WorkflowType } from "./CiGenerator";

const PROVIDER_LABELS = { bitbucket: "Bitbucket Pipelines", github: "GitHub Actions", gitlab: "GitLab CI" };

Expand Down Expand Up @@ -71,6 +71,19 @@ export class ActionsInitCommand extends BaseCommand {
log.info(`\nAdd this to your ${color.bold(".gitlab-ci.yml")}:\n`);
log.info(color.cyan(generator.getIncludeInstruction()));
log.info("");
log.info(
`${color.yellow("Note:")} Add a ${color.bold("GITLAB_TOKEN")} CI/CD variable (Personal Access Token with ${color.bold("api")} + ${color.bold("write_repository")} scopes)`,
);
log.info(color.dim("Required for creating release PRs. Settings → CI/CD → Variables"));
log.info("");
}

if (generator instanceof GitHubCiGenerator) {
log.info(
`\n${color.yellow("Note:")} Enable ${color.bold("\"Allow GitHub Actions to create and approve pull requests\"")}`,
);
log.info(color.dim("Settings → Actions → General → Workflow permissions"));
log.info("");
}

log.info(color.dim("Commit and push these files to enable the workflows."));
Expand Down
17 changes: 6 additions & 11 deletions packages/sisyphus/src/commands/actions/release-pr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ export class ActionsReleasePrCommand extends BaseCommand {
private async getProvider(): Promise<GitProvider> {
if (!this.provider) {
this.provider = await createGitProvider();
await this.provider.ensureAvailable();
}
return this.provider;
}
Expand All @@ -40,7 +39,6 @@ export class ActionsReleasePrCommand extends BaseCommand {

if (stones.length === 0) {
log.info(color.dim("No pending stones found, skipping release PR"));
await Bun.$`git checkout ${baseBranch}`;
return;
}

Expand All @@ -54,16 +52,13 @@ export class ActionsReleasePrCommand extends BaseCommand {
log.info(color.yellow("\n[dry-run] Would create/update release PR"));
log.info(color.dim("\nPR Body preview:"));
log.info(prBody);
await Bun.$`git checkout ${baseBranch}`;
return;
}

await this.commitAndPushChanges(ctx, stones, packages);
await this.createOrUpdatePr(prTitle, prBody);
await Bun.$`git checkout ${baseBranch}`;
} catch (error) {
await this.restoreMainBranch();
throw error;
} finally {
await this.restoreMainBranch(baseBranch);
}
}

Expand Down Expand Up @@ -102,7 +97,9 @@ export class ActionsReleasePrCommand extends BaseCommand {
s.start("Applying release changes...");

const changedFiles = await this.applyReleaseChanges(ctx, stones, packages);
await Bun.$`git add ${changedFiles}`;
for (const file of changedFiles) {
await Bun.$`git add ${file}`.nothrow();
}

const hasChanges = await Bun.$`git diff --cached --quiet`.nothrow();
if (hasChanges.exitCode !== 0) {
Expand Down Expand Up @@ -313,9 +310,7 @@ export class ActionsReleasePrCommand extends BaseCommand {
await provider.updatePr(prNumber, { body, title });
}

private async restoreMainBranch() {
const provider = await this.getProvider();
const baseBranch = await provider.getDefaultBranch();
private async restoreMainBranch(baseBranch: string) {
try {
await Bun.$`git checkout ${baseBranch}`.quiet();
} catch (error) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
name: Sisyphus - Handle PR Merge

on:
push:
branches: [main]
pull_request:
types: [closed]
branches: [main]

concurrency:
group: sisyphus-${{ github.ref }}
cancel-in-progress: false

jobs:
handle-merge:
if: github.event.pull_request.merged == true && !contains(github.event.pull_request.labels.*.name, 'sisyphus-release')
if: github.event_name == 'pull_request' && github.event.pull_request.merged == true && !contains(github.event.pull_request.labels.*.name, 'sisyphus-release')
runs-on: ubuntu-latest
permissions:
contents: write
Expand Down Expand Up @@ -54,3 +60,29 @@ jobs:
run: bunx @r5n/sisyphus actions release-pr
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

handle-push:
if: github.event_name == 'push' && github.event.head_commit.committer.username != 'web-flow'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- uses: oven-sh/setup-bun@v2

- name: Install dependencies
run: bun install

- name: Configure git
run: |
git config user.name "r5n-bot[bot]"
git config user.email "r5n-bot[bot]@users.noreply.github.com"

- name: Update release PR
run: bunx @r5n/sisyphus actions release-pr
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Original file line number Diff line number Diff line change
@@ -1,15 +1,26 @@
handle-merge:
stage: sisyphus
image: oven/bun:latest
image: oven/bun:alpine
before_script:
- apk add --no-cache git
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_STATE == "merged" && $CI_MERGE_REQUEST_LABELS !~ /sisyphus-release/
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $CI_COMMIT_TITLE =~ /^Merge branch/ && $CI_COMMIT_TITLE !~ /sisyphus\/release/
script:
- |
MR_IID=$(echo "$CI_COMMIT_DESCRIPTION" | grep -o '![0-9]*' | grep -o '[0-9]*' | head -1)
if [ -z "$MR_IID" ]; then
echo "No MR IID found in commit description, skipping"
exit 0
fi
echo "Detected MR !$MR_IID"
- git fetch --unshallow || true
- git config user.name "r5n-bot[bot]"
- git config user.email "r5n-bot[bot]@users.noreply.gitlab.com"
- bun install
- |
OUTPUT=$(bunx @r5n/sisyphus pr --url $CI_MERGE_REQUEST_PROJECT_URL/-/merge_requests/$CI_MERGE_REQUEST_IID --yes 2>&1) && EXIT_CODE=0 || EXIT_CODE=$?
GIT_TOKEN="${GITLAB_TOKEN:-$CI_JOB_TOKEN}"
GIT_USER=$([ -n "$GITLAB_TOKEN" ] && echo "oauth2" || echo "gitlab-ci-token")
OUTPUT=$(bunx @r5n/sisyphus pr --url $CI_PROJECT_URL/-/merge_requests/$MR_IID --yes 2>&1) && EXIT_CODE=0 || EXIT_CODE=$?
echo "$OUTPUT"
if echo "$OUTPUT" | grep -q "Skipping:"; then
echo "Stone creation skipped"
Expand All @@ -19,8 +30,20 @@ handle-merge:
exit $EXIT_CODE
fi
git add .sisyphus/stones/
git diff --cached --quiet || git commit -m 'chore: add stone from MR !'"$CI_MERGE_REQUEST_IID"
git push https://oauth2:$GITLAB_TOKEN@$CI_SERVER_HOST/$CI_PROJECT_PATH.git HEAD:$CI_DEFAULT_BRANCH
git diff --cached --quiet || git commit -m 'chore: add stone from MR !'"$MR_IID"
git push "https://${GIT_USER}:${GIT_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git" HEAD:$CI_DEFAULT_BRANCH
bunx @r5n/sisyphus actions release-pr
variables:
GITLAB_TOKEN: $GITLAB_TOKEN

handle-push:
stage: sisyphus
image: oven/bun:alpine
before_script:
- apk add --no-cache git
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $CI_COMMIT_TITLE !~ /^Merge branch/
script:
- git fetch --unshallow || true
- git config user.name "r5n-bot[bot]"
- git config user.email "r5n-bot[bot]@users.noreply.gitlab.com"
- bun install
- bunx @r5n/sisyphus actions release-pr
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
release:
stage: sisyphus
image: oven/bun:latest
image: oven/bun:alpine
before_script:
- apk add --no-cache git
rules:
- if: $CI_PIPELINE_SOURCE == "web"
when: manual
- if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_STATE == "merged" && $CI_MERGE_REQUEST_LABELS =~ /sisyphus-release/
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $CI_COMMIT_TITLE =~ /^Merge branch.*sisyphus\/release/
script:
- git fetch --unshallow || true
- bun install
- bunx @r5n/sisyphus roll --publish-only --yes
variables:
GITLAB_TOKEN: $GITLAB_TOKEN
NPM_TOKEN: $NPM_TOKEN
12 changes: 12 additions & 0 deletions packages/sisyphus/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export class InitCommand extends BaseCommand {

if (useDefault) {
ctx.config.save(SISYPHUS_DEFAULT_CONFIG);
await this.setLastStoneToHead(ctx);
this.displaySuccessNote(ctx.cli.version);
return;
}
Expand All @@ -49,6 +50,7 @@ export class InitCommand extends BaseCommand {
const mergedConfig = deepMerge<SisyphusConfig>({ ...currentConfig }, newConfig);

ctx.config.save(mergedConfig);
await this.setLastStoneToHead(ctx);
this.displaySuccessNote(ctx.cli.version);
}

Expand Down Expand Up @@ -187,6 +189,16 @@ export class InitCommand extends BaseCommand {
} as InitFormValues;
}

private async setLastStoneToHead(ctx: InitCtx) {
try {
const result = await Bun.$`git rev-parse HEAD`.quiet();
const commit = result.stdout.toString().trim();
if (commit) {
ctx.config.set("lastStone", { commit, date: new Date().toISOString() });
}
} catch {}
}

private displaySuccessNote(version?: string) {
note(
`${color.magenta(`${color.bold("Sisyphus ")}${color.underline(color.italic(`v${version ?? "unknown"}`))}`)}${color.green(" is setup to roll stones.")}
Expand Down
3 changes: 1 addition & 2 deletions packages/sisyphus/src/commands/roll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,6 @@ export class RollCommand extends BaseCommand {
s.stop("Release created");
}

ctx.config.set("currentRelease", undefined);
ctx.config.set("lastStone", { commit: await this.getCurrentCommit(), date: new Date().toISOString() });

note(
Expand All @@ -360,7 +359,7 @@ export class RollCommand extends BaseCommand {
}

private createFallbackStone(packages: Package[]): Stone {
const message = `Release ${packages.map((p) => `${p.name}@${p.version}`).join(", ")}`;
const message = `Release ${packages.map((p) => `${p.name}@${p.newVersion ?? p.version}`).join(", ")}`;
return Stone.create({ message }, 0);
}
}
19 changes: 12 additions & 7 deletions packages/sisyphus/src/domain/Commit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,21 +120,26 @@ export class Commit {
}

private static async fetchFromRange(range: string): Promise<Commit[]> {
const result = await Bun.$`git log ${range} --pretty=format:"%H%x1f%s%x1f%an" --no-merges`.quiet();
const format = "%x00%H%x1f%s%x1f%an%x1f%b%x00";
const result = await Bun.$`git log ${range} --pretty=format:${format} --name-only --no-merges`.quiet();
const output = result.stdout.toString().trim();

if (!output) return [];

const segments = output.split("\x00").filter(Boolean);
const commits: Commit[] = [];

for (const line of output.split("\n")) {
const parts = line.split(FIELD_SEPARATOR);
if (parts.length < 3) continue;
const [hash, subject, author] = parts;
for (let i = 0; i < segments.length; i += 2) {
const fields = segments[i]!.split(FIELD_SEPARATOR);
if (fields.length < 3) continue;

const [hash, subject, author] = fields;
if (!hash || !subject || !author) continue;

const commit = await Commit.hydrate(hash, subject, author);
commits.push(commit);
const body = fields.slice(3).join(FIELD_SEPARATOR).trim() || undefined;
const files = (segments[i + 1] ?? "").split("\n").filter(Boolean);

commits.push(Commit.parse(hash, subject, author).withFiles(files).withBody(body));
}

return commits;
Expand Down
14 changes: 9 additions & 5 deletions packages/sisyphus/src/providers/GitHubProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ type GitHubCliPrResponse = {
export class GitHubProvider extends GitProvider {
readonly name = "github" as const;

private get apiPath(): string {
return `repos/${encodeURIComponent(this.owner)}/${encodeURIComponent(this.repo)}`;
}

async ensureAvailable(): Promise<void> {
try {
await Bun.$`which gh`.quiet();
Expand Down Expand Up @@ -83,14 +87,14 @@ export class GitHubProvider extends GitProvider {

async createPr(options: CreatePrOptions): Promise<PullRequest> {
const result =
await Bun.$`gh api repos/${this.owner}/${this.repo}/pulls --method POST -f head=${options.head} -f base=${options.base} -f title=${options.title} -f body=${options.body}`;
await Bun.$`gh api ${this.apiPath}/pulls --method POST -f head=${options.head} -f base=${options.base} -f title=${options.title} -f body=${options.body}`;

const data = JSON.parse(result.stdout.toString());
const prNumber = data.number as number;

if (options.labels && options.labels.length > 0) {
const labelArgs = options.labels.flatMap((l) => ["-f", `labels[]=${l}`]);
await Bun.$`gh api repos/${this.owner}/${this.repo}/issues/${prNumber}/labels --method POST ${labelArgs}`;
await Bun.$`gh api ${this.apiPath}/issues/${prNumber}/labels --method POST ${labelArgs}`;
}

return this.getPr(prNumber);
Expand All @@ -106,7 +110,7 @@ export class GitHubProvider extends GitProvider {

async getPr(number: number): Promise<PullRequest> {
try {
const result = await Bun.$`gh api repos/${this.owner}/${this.repo}/pulls/${number}`.quiet();
const result = await Bun.$`gh api ${this.apiPath}/pulls/${number}`.quiet();
const data = JSON.parse(result.stdout.toString());

return this.mapRestApiResponse(data);
Expand All @@ -130,7 +134,7 @@ export class GitHubProvider extends GitProvider {
async getPrCommits(number: number): Promise<string[]> {
try {
const result =
await Bun.$`gh api repos/${this.owner}/${this.repo}/pulls/${number}/commits --jq '.[].sha'`.quiet();
await Bun.$`gh api ${this.apiPath}/pulls/${number}/commits --jq '.[].sha'`.quiet();
return result.stdout.toString().trim().split("\n").filter(Boolean);
} catch {
return [];
Expand All @@ -140,7 +144,7 @@ export class GitHubProvider extends GitProvider {
async getPrFiles(number: number): Promise<string[]> {
try {
const result =
await Bun.$`gh api repos/${this.owner}/${this.repo}/pulls/${number}/files --jq '.[].filename'`.quiet();
await Bun.$`gh api ${this.apiPath}/pulls/${number}/files --jq '.[].filename'`.quiet();
return result.stdout.toString().trim().split("\n").filter(Boolean);
} catch {
return [];
Expand Down
Loading