Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 .changeset/preview-existing-workspace-jq-capture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"tailor-platform-actions": patch
---

Fix preview-deploy and preview-cleanup not finding the workspace ID recorded in the PR comment when jq rejects the `(?P<id>…)` named-group syntax, as the jq 1.7.1 and 1.8.2 release binaries do. `try` hid the error, so an existing preview workspace went unrecognized: a later push tried to create it again and closing the PR skipped deleting it. The group is now written `(?<id>…)`, which those jq versions accept.
5 changes: 5 additions & 0 deletions .changeset/preview-workspace-ttl.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"tailor-platform-actions": minor
---

Add `ttl` to preview-deploy and `prune-expired` (with `organization-id` and `folder-id`) to preview-cleanup. With `ttl`, a preview workspace records an expiry that every push to the PR restarts, and with `prune-expired: "true"` every PR close also deletes this app's other `{prefix}-pr-{number}` workspaces in the same folder or organization root whose expiry has passed, so a preview whose own cleanup never ran no longer lingers. A PR that sat idle past `ttl` gets a fresh workspace on its next push, and closing it after its workspace was pruned no longer fails. Both inputs are off by default and existing workflows behave as before.
1 change: 1 addition & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ jobs:
- run: pnpm test:deploy-outputs
- run: pnpm test:drift-check
- run: pnpm test:plan
- run: pnpm test:preview
- run: pnpm test:tag-guard

internal-refs:
Expand Down
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,7 @@ jobs:
| `region` | Yes | | Workspace region for creation (e.g. `us-west`, `asia-northeast`). Only used on first run. |
| `organization-id` | No | | Organization ID for workspace creation. Defaults to `TAILOR_PLATFORM_ORGANIZATION_ID` env var. |
| `folder-id` | No | | Folder ID for workspace creation |
| `ttl` | No | | Duration (e.g. `7d`, `24h`) after which the workspace can be deleted by `preview-cleanup`'s `prune-expired`. Every push restarts it, so it counts from the last push. Empty records no expiry and leaves an existing workspace's expiry untouched. |
| `working-directory` | No | `.` | Working directory (for monorepo setups) |
| `package-manager` | No | | Package manager (`pnpm`, `npm`, `yarn`, or `bun`). Defaults to `npx`. |
| `platform-client-id` | Yes | | OAuth2 client ID for machine user |
Expand Down Expand Up @@ -703,12 +704,38 @@ jobs:
| Name | Required | Default | Description |
|------|----------|---------|-------------|
| `workspace-name-prefix` | Yes | | Same prefix used in `preview-deploy` |
| `prune-expired` | No | `false` | Set to `true` to also delete this app's other preview workspaces whose `ttl` has passed. See [Pruning expired previews](#pruning-expired-previews). |
| `organization-id` | No | | Organization whose root is swept when `folder-id` is empty. Use the same value as `preview-deploy`. Defaults to `TAILOR_PLATFORM_ORGANIZATION_ID` env var. Only used with `prune-expired`. |
| `folder-id` | No | | Folder swept for expired previews. Use the same value as `preview-deploy`. Defaults to `TAILOR_PLATFORM_FOLDER_ID` env var. Takes precedence over `organization-id`. Only used with `prune-expired`. |
| `working-directory` | No | `.` | Working directory (for monorepo setups) |
| `package-manager` | No | | Package manager (`pnpm`, `npm`, `yarn`, or `bun`). Defaults to `npx`. |
| `platform-client-id` | Yes | | OAuth2 client ID for machine user |
| `platform-client-secret` | Yes | | OAuth2 client secret for machine user |
| `github-token` | Yes | | GitHub token with `pull-requests: write` for reading and updating the PR comment |

#### Pruning expired previews

A close-time cleanup that fails, is disabled, or never runs (a PR left open for a long time) leaves its preview workspace behind. Pass `ttl` to `preview-deploy` so each workspace records an expiry when it is created, and set `prune-expired: "true"` here so every PR close also deletes the expired ones:

```yaml
- uses: tailor-platform/actions/preview-cleanup@v2
with:
workspace-name-prefix: my-app
prune-expired: "true"
folder-id: ${{ vars.TAILOR_PLATFORM_FOLDER_ID }}
platform-client-id: ${{ secrets.TAILOR_PLATFORM_MACHINE_USER_CLIENT_ID }}
platform-client-secret: ${{ secrets.TAILOR_PLATFORM_MACHINE_USER_CLIENT_SECRET }}
github-token: ${{ secrets.GITHUB_TOKEN }}
```

- The sweep runs after the per-PR workspace is deleted and the PR comment is updated, and it runs even when that deletion failed.
- It only considers workspaces in the folder (or, without `folder-id`, directly under the organization) whose whole name is `{workspace-name-prefix}-pr-{number}`, so other apps' workspaces in the same location stay out of it. Use the same location as `preview-deploy`.
- A workspace with no recorded expiry (created without `ttl`) is never deleted by the sweep.
- The sweep runs `workspace prune` with `--limit 0`, so it deletes every expired match. With the CLI default of 20, a backlog of more than 20 would abort the sweep without deleting anything on every later PR close. The name and location filters above are what keep it narrow.
- With none of `folder-id`, `organization-id`, `TAILOR_PLATFORM_FOLDER_ID` or `TAILOR_PLATFORM_ORGANIZATION_ID`, the sweep is skipped with a warning and the rest of the cleanup is unaffected.
- If a PR sits idle past `ttl`, its workspace is swept while the PR is still open. The next push to that PR creates a fresh workspace under the same name and updates the PR comment with the new ID. Closing such a PR after the sweep is not an error: `preview-cleanup` reports that the workspace is already gone and still updates the comment.
- A workspace restored after it expired is deleted again by the next sweep unless its expiry is changed with `tailor workspace ttl set` or `ttl clear`.

---

### [`relevance`](relevance/action.yaml)
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"test:lockfile-audit-fix": "node --test lockfile-audit-fix/*.test.mjs",
"test:create-signed-pr": "node --test create-signed-pr/*.test.mjs",
"test:cli-bin-contract": "node --test tests/cli-bin-contract.test.mjs",
"test:preview": "node --test tests/preview/*.test.mjs",
"test:drift-check": "node --test drift-check/*.test.mjs",
"changeset": "changeset",
"test:plan": "node --test plan/*.test.mjs",
Expand Down
66 changes: 63 additions & 3 deletions preview-cleanup/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,34 @@ inputs:
The same prefix used in preview-deploy. Used to locate the PR comment
that holds the workspace ID.
required: true
prune-expired:
description: >-
Set to "true" to also delete this app's other preview workspaces whose
expiry (the `ttl` input of preview-deploy) has passed, right after the
per-PR workspace is deleted. Workspaces without a recorded expiry are
never deleted this way. Runs even when deleting the per-PR workspace
failed. Needs `folder-id`, `organization-id`, or the
TAILOR_PLATFORM_FOLDER_ID / TAILOR_PLATFORM_ORGANIZATION_ID environment
variable; with none of them the sweep is skipped with a warning.
required: false
default: "false"
organization-id:
description: >-
Organization whose root (workspaces outside any folder) is swept when
`folder-id` is empty. Use the same value as preview-deploy. Defaults to
the TAILOR_PLATFORM_ORGANIZATION_ID environment variable. Only used when
`prune-expired` is "true".
required: false
default: ""
folder-id:
description: >-
Folder swept for expired preview workspaces. Use the same value as
preview-deploy. Defaults to the TAILOR_PLATFORM_FOLDER_ID environment
variable. Takes precedence over `organization-id`, matching where
preview-deploy creates the workspace. Only used when `prune-expired` is
"true".
required: false
default: ""
working-directory:
description: Working directory for the project (for monorepo setups)
required: false
Expand Down Expand Up @@ -66,15 +94,22 @@ runs:
'[ .[][] | select(.user.type == "Bot" and (.body | startswith($m))) ] | first | .body // empty')

WORKSPACE_ID=$(echo "$COMMENT" | \
jq -rR '(try capture("<!-- tailor-preview: [^ ]+ id=(?P<id>[a-f0-9-]+)") | .id) // empty')
jq -rR '(try capture("<!-- tailor-preview: [^ ]+ id=(?<id>[a-f0-9-]+)") | .id) // empty')

if [ -z "$WORKSPACE_ID" ]; then
echo "::warning::No preview workspace ID found in PR comment — skipping deletion"
exit 0
fi

echo "Deleting preview workspace: $WORKSPACE_NAME ($WORKSPACE_ID)"
$TAILOR_RUN tailor workspace delete --workspace-id "$WORKSPACE_ID" --yes
if DELETE_OUTPUT=$($TAILOR_RUN tailor workspace delete --workspace-id "$WORKSPACE_ID" --yes 2>&1); then
printf '%s\n' "$DELETE_OUTPUT"
elif printf '%s' "$DELETE_OUTPUT" | grep -qiE 'not_found|workspace .*not found'; then
echo "::warning::Preview workspace $WORKSPACE_ID no longer exists (expired and pruned?) — nothing to delete"
else
printf '%s\n' "$DELETE_OUTPUT" >&2
exit 1
fi

echo "workspace-id=$WORKSPACE_ID" >> "$GITHUB_OUTPUT"
echo "workspace-name=$WORKSPACE_NAME" >> "$GITHUB_OUTPUT"
Expand Down Expand Up @@ -105,4 +140,29 @@ runs:
with:
marker-prefix: "<!-- tailor-preview: ${{ steps.delete.outputs.workspace-name }} "
body: ${{ steps.body.outputs.body }}
github-token: ${{ inputs.github-token }}
github-token: ${{ inputs.github-token }}

- name: Prune expired preview workspaces
id: prune
if: ${{ !cancelled() && inputs.prune-expired == 'true' }}
shell: bash
env:
NAME_PREFIX: ${{ inputs.workspace-name-prefix }}
ORG_ID: ${{ inputs.organization-id }}
FOLDER_ID: ${{ inputs.folder-id }}
run: |
ORG_ID="${ORG_ID:-${TAILOR_PLATFORM_ORGANIZATION_ID:-}}"
Comment thread
Copilot marked this conversation as resolved.
FOLDER_ID="${FOLDER_ID:-${TAILOR_PLATFORM_FOLDER_ID:-}}"
if [ -n "$FOLDER_ID" ]; then
LOCATION=(--folder-id "$FOLDER_ID")
elif [ -n "$ORG_ID" ]; then
LOCATION=(--organization-root "$ORG_ID")
else
echo "::warning::prune-expired needs folder-id or organization-id — skipping the sweep of expired preview workspaces"
exit 0
fi

NAME_PATTERN="$(printf '%s' "$NAME_PREFIX" | sed 's/[][\.*^$(){}?+|/]/\\&/g')-pr-[0-9]+"

echo "Pruning expired preview workspaces matching: $NAME_PATTERN"
$TAILOR_RUN tailor workspace prune --expired --yes --limit 0 "${LOCATION[@]}" --name "$NAME_PATTERN"
46 changes: 41 additions & 5 deletions preview-deploy/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ inputs:
description: Folder ID to associate the workspace with on creation
required: false
default: ""
ttl:
description: >-
Duration after creation (e.g. 7d, 24h) at which the preview workspace
becomes prunable by `tailor workspace prune --expired` (see the
`prune-expired` input of preview-cleanup). Every push restarts it, so it
counts from the last push; a workspace that was pruned while its PR was
idle is created again on the next push. Empty (default) records no
expiry and leaves an existing workspace's expiry untouched.
required: false
default: ""
working-directory:
description: Working directory for the project (for monorepo setups)
required: false
Expand Down Expand Up @@ -83,6 +93,7 @@ runs:
REGION: ${{ inputs.region }}
ORG_ID: ${{ inputs.organization-id }}
FOLDER_ID: ${{ inputs.folder-id }}
TTL: ${{ inputs.ttl }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: | # zizmor: ignore[github-env]
if [ -z "$PR_NUMBER" ]; then
Expand All @@ -102,21 +113,46 @@ runs:
"repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" | \
jq -r --arg m "$MARKER_PREFIX" \
'[ .[][] | select(.user.type == "Bot" and (.body | startswith($m))) ] | first | .body // empty' | \
jq -rR '(try capture("<!-- tailor-preview: [^ ]+ id=(?P<id>[a-f0-9-]+)") | .id) // empty')
jq -rR '(try capture("<!-- tailor-preview: [^ ]+ id=(?<id>[a-f0-9-]+)") | .id) // empty')

WORKSPACE_ID=""
if [ -n "$EXISTING_ID" ]; then
echo "Found existing preview workspace: $EXISTING_ID"
WORKSPACE_ID="$EXISTING_ID"
else
if GET_OUTPUT=$($TAILOR_RUN tailor workspace get --workspace-id "$EXISTING_ID" --json 2>&1); then
echo "Found existing preview workspace: $EXISTING_ID"
WORKSPACE_ID="$EXISTING_ID"
if [ -n "$TTL" ]; then
$TAILOR_RUN tailor workspace ttl set --workspace-id "$WORKSPACE_ID" --ttl "$TTL" ||
echo "::warning::Could not extend the expiry of preview workspace $WORKSPACE_ID — it may be pruned sooner than $TTL after this push"
fi
elif printf '%s' "$GET_OUTPUT" | grep -qiE 'not_found|workspace .*not found'; then
echo "::warning::Preview workspace $EXISTING_ID recorded in the PR comment no longer exists (expired and pruned?) — creating a new one"
else
printf '%s\n' "$GET_OUTPUT" >&2
echo "::error::Could not check whether preview workspace $EXISTING_ID still exists"
exit 1
fi
fi

if [ -z "$WORKSPACE_ID" ]; then
echo "Creating preview workspace: $WORKSPACE_NAME (region: $REGION)"
CREATE_ARGS=(workspace create --name "$WORKSPACE_NAME" --region "$REGION" --json)
[ -n "$ORG_ID" ] && CREATE_ARGS+=(--organization-id "$ORG_ID")
[ -n "$FOLDER_ID" ] && CREATE_ARGS+=(--folder-id "$FOLDER_ID")
WORKSPACE_ID=$($TAILOR_RUN tailor "${CREATE_ARGS[@]}" | jq -r '.id // empty')
[ -n "$TTL" ] && CREATE_ARGS+=(--ttl "$TTL")
CREATE_STATUS=0
CREATE_OUTPUT=$($TAILOR_RUN tailor "${CREATE_ARGS[@]}") || CREATE_STATUS=$?
WORKSPACE_ID=$(printf '%s' "$CREATE_OUTPUT" | jq -r '.id // empty' 2>/dev/null || true)
if [ -z "$WORKSPACE_ID" ]; then
echo "::error::Failed to create preview workspace '$WORKSPACE_NAME'"
exit 1
fi
if [ "$CREATE_STATUS" -ne 0 ]; then
echo "::warning::workspace create exited with $CREATE_STATUS after creating $WORKSPACE_ID (the expiry could not be confirmed) — setting it again"
if [ -n "$TTL" ]; then
$TAILOR_RUN tailor workspace ttl set --workspace-id "$WORKSPACE_ID" --ttl "$TTL" ||
echo "::warning::Could not set the expiry of preview workspace $WORKSPACE_ID — it may never be pruned"
fi
fi
fi

echo "workspace-id=$WORKSPACE_ID" >> "$GITHUB_OUTPUT"
Expand Down
50 changes: 50 additions & 0 deletions tests/preview/existing-workspace.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import test from "node:test";
import { loadAction, runStep } from "./helpers.mjs";

const deployEnv = {
NAME_PREFIX: "my-app",
REGION: "us-west",
ORG_ID: "",
FOLDER_ID: "",
PR_NUMBER: "42",
};

test("preview-deploy: reuses the workspace recorded in the PR comment instead of creating another", async (context) => {
const action = await loadAction("preview-deploy");
const result = await runStep(context, {
action,
stepId: "workspace",
env: { ...deployEnv, TTL: "7d" },
comments: [
{
user: { type: "Bot" },
body: "<!-- tailor-preview: my-app-pr-42 id=0a1b2c3d-0000-4000-8000-000000000000 -->",
},
],
});
assert.equal(result.code, 0, result.stderr);
assert.deepEqual(
result.calls.filter((args) => args[2] === "create"),
[],
);
});

test("preview-cleanup: deletes the workspace recorded in the PR comment", async (context) => {
const action = await loadAction("preview-cleanup");
const result = await runStep(context, {
action,
stepId: "delete",
env: { NAME_PREFIX: "my-app", PR_NUMBER: "42" },
comments: [
{
user: { type: "Bot" },
body: "<!-- tailor-preview: my-app-pr-42 id=0a1b2c3d-0000-4000-8000-000000000000 -->",
},
],
});
assert.equal(result.code, 0, result.stderr);
assert.deepEqual(result.calls, [
["tailor", "workspace", "delete", "--workspace-id", "0a1b2c3d-0000-4000-8000-000000000000", "--yes"],
]);
});
Loading
Loading