Manual Jobs, Delayed Jobs, Scheduled Pipelines, Rollbacks, Freeze Windows, and Release Controls: Guided Hands-On Workflow and Core Operations
Operate a disposable release-control pipeline with explicit manual behavior, a short delayed job, schedule ownership, freeze policy, stale-path handling, and known-artifact recovery.
Learning objectives
- Inspect current release-control state before modifying CI/CD or schedules.
- Run safe optional and blocking manual jobs and prove their different pipeline semantics.
- Observe a short delayed job, unschedule/cancel it safely, and correlate timestamps.
- Create and inspect an inactive pipeline schedule without recurring side effects.
- Model freeze behavior and known-artifact recovery with synthetic deployment state.
manual_confirmation, delayed jobs with
start_in, pipeline schedules, deployment freeze periods /
CI_DEPLOY_FREEZE, environment/deployment history, the
project setting that prevents outdated deployment jobs, and
rollback/retry controls are available on GitLab Free/Premium/Ultimate
across GitLab.com, Self-Managed, and Dedicated. Protected environments
and deployment approvals—the fine-grained “only these people may
deploy” layer—remain Premium/Ultimate. The mandatory chapter path uses
only a disposable project, synthetic artifacts/environments, and tiny
jobs. No production endpoint, cloud account, paid tier, runner
registration, real credential, or release publication is required.
1. Scenario: release control with zero external side effect
Use a disposable GitLab Free project or branch named
ch18/release-control-lab. The workflow creates one tiny
text artifact and “deploys” it only by validating its checksum and
recording a synthetic GitLab environment such as
release-control/ch18. The external URL is
example.invalid. No registry, cloud, Kubernetes
cluster, package publication, production tag, or real secret is
used.
| Preflight | Required state | Why |
|---|---|---|
| Project | Disposable or dedicated lab project. | You will add CI config and optionally an inactive schedule/freeze period. |
| Role | Developer for normal pipeline/manual work; Maintainer/Owner only for schedule/freeze/project-setting operations that require it. | Least privilege. |
| Runner | Tiny hosted/project runner if available; otherwise CI Lint + supplied expected-state fixtures. | No paid compute dependency. |
| Environment | Synthetic only: release-control/ch18. |
No external state can be harmed. |
| Secrets | None. | This chapter does not test secret handling. |
2. Inspect before-state and record ownership
PROJECT_ID="12345678"
glab api "projects/$PROJECT_ID" --jq '{id,path_with_namespace,default_branch,ci_forward_deployment_enabled,ci_forward_deployment_rollback_allowed}'
glab api "projects/$PROJECT_ID/pipeline_schedules" --paginate \
--jq '.[] | {id,description,ref,cron,cron_timezone,active,owner:.owner.username}'
glab api "projects/$PROJECT_ID/freeze_periods" --paginate \
--jq '.[] | {id,freeze_start,freeze_end,cron_timezone}'
glab api "projects/$PROJECT_ID/environments?name=release-control/ch18" \
--jq '.[] | {id,name,state,external_url,last_deployment:(.last_deployment.id // null)}'
If an existing schedule/freeze/environment belongs to someone else, do not repurpose it. Use uniquely named disposable resources and record their IDs for targeted cleanup.
3. Add a deterministic release artifact and three control jobs
stages: [build, gate, deploy]
build_release:
stage: build
script:
- mkdir -p out
- printf 'commit=%s\npipeline=%s\n' "$CI_COMMIT_SHA" "$CI_PIPELINE_ID" > out/release.txt
- sha256sum out/release.txt > out/release.txt.sha256
artifacts:
paths: [out/release.txt, out/release.txt.sha256]
expire_in: 2 days
access: developer
optional_preview:
stage: gate
needs: [build_release]
script:
- echo "optional_preview=true"
when: manual
allow_failure: true
manual_confirmation: "Run optional synthetic preview?"
release_gate:
stage: gate
needs: [build_release]
script:
- sha256sum -c out/release.txt.sha256
- echo "release_gate=approved_by_manual_start"
rules:
- if: '$CI_COMMIT_BRANCH'
when: manual
allow_failure: false
wait_before_deploy:
stage: deploy
needs:
- job: build_release
artifacts: true
- job: release_gate
artifacts: false
script:
- sha256sum -c out/release.txt.sha256
- echo "delayed_window_complete=true"
when: delayed
start_in: 20 seconds
environment:
name: release-control/ch18
url: https://ch18.example.invalid
resource_group: ch18-release
4. Validate before pushing
Use Pipeline Editor / CI Lint. The expected merged semantics are:
-
optional_previewmay be ignored without blocking the pipeline. -
release_gateis blocking becauseallow_failure: falseis explicit. -
wait_before_deploydoes not begin its delay until the gate succeeds and the deploy stage becomes eligible. - The deploy job verifies the exact artifact checksum; it does not rebuild anything.
-
resource_groupprevents concurrent mutation of this synthetic environment.
5. Commit to a disposable branch
git switch -c ch18/release-control-lab
git add .gitlab-ci.yml
git diff --cached --check
git diff --cached
git commit -m "ch18: release control lab"
LAB_SHA="$(git rev-parse HEAD)"
printf 'lab_sha=%s\n' "$LAB_SHA"
git push -u origin ch18/release-control-lab
6. Observe manual-state differences before clicking anything
Inspect the pipeline. Record pipeline ID/SHA/status and job
statuses. Predict that optional_preview is
skipped/optional, while release_gate leaves the
pipeline blocked. Verify from the jobs API:
PIPELINE_ID="123456789"
glab api "projects/$PROJECT_ID/pipelines/$PIPELINE_ID" --jq '{id,sha,ref,source,status}'
glab api "projects/$PROJECT_ID/pipelines/$PIPELINE_ID/jobs?scope[]=manual&scope[]=skipped" --paginate \
--jq '.[] | {id,name,status,allow_failure,stage,ref}'
7. Run the blocking gate deliberately
Use the GitLab UI to open release_gate, confirm it is
tied to LAB_SHA, then run it. The actor must have
permission to merge to the assigned branch. Do not pass any secret
variables. Record the job ID and user shown in the job/deployment
evidence.
After the gate succeeds, verify the delayed deploy is scheduled rather than pending/running immediately.
8. Observe the short delay and exercise Unschedule
For the first run, let the 20-second delay complete and record
created_at, started_at, and
finished_at. The runner may add queue time, so do not
expect the observed wall-clock difference to equal exactly 20
seconds.
For a second disposable pipeline, trigger the gate, then select Unschedule on the delayed job. Verify the automatic timer is canceled. You can leave it manual or deliberately run it after confirming the SHA; do not hide the state change.
glab api "projects/$PROJECT_ID/pipelines/$PIPELINE_ID/jobs" --paginate \
--jq '.[] | select(.name=="wait_before_deploy") | {id,name,status,created_at,started_at,finished_at,sha}'
9. Create an inactive schedule as a safe server-side object
Preferred UI path: Build → Pipeline schedules → New schedule. Use a clear description, a harmless cron such as daily, the disposable branch, UTC, and leave it inactive if your UI/API supports that path. Do not add credentials.
API alternative for a disposable project:
SCHEDULE_JSON="$(glab api --method POST "projects/$PROJECT_ID/pipeline_schedules" \
--field description='ch18 disposable schedule' \
--field ref='ch18/release-control-lab' \
--field cron='17 3 * * *' \
--field cron_timezone='UTC' \
--field active='false')"
SCHEDULE_ID="$(printf '%s' "$SCHEDULE_JSON" | python -c 'import json,sys; print(json.load(sys.stdin)["id"])')"
printf 'schedule_id=%s\n' "$SCHEDULE_ID"
glab api "projects/$PROJECT_ID/pipeline_schedules/$SCHEDULE_ID" \
--jq '{id,description,ref,cron,cron_timezone,active,owner:.owner.username,next_run_at}'
The schedule should not create recurring pipelines while inactive. If your GitLab version/API rejects inactive creation, create it in the UI, immediately deactivate it, and verify no pipeline ran before proceeding.
10. Prove source/identity without consuming a recurring schedule
A genuine scheduled pipeline has
CI_PIPELINE_SOURCE=schedule. You may use the schedule’s
one-time Run action if you want a tiny pipeline and
have quota; remember that manual Run uses your permissions
instead of the stored owner. The no-compute path uses this expected
fixture:
{
"source": "schedule",
"owner": "schedule-owner",
"target_ref": "refs/heads/ch18/release-control-lab",
"active": false,
"policy": "no recurring execution during lab"
}
11. Model a deploy freeze safely
Because freeze periods are Free, a Maintainer can create one in a
disposable project. However, cron time windows are easy to
miscalculate. The mandatory lab can stay read-only: inspect the
current freeze list and evaluate a fixture where
CI_DEPLOY_FREEZE=true. Optional live exercise: add a
short, clearly named freeze period that includes the current time,
validate the time zone carefully, create one tiny pipeline, then
remove the freeze period immediately.
Use this policy pattern:
deploy_release:
stage: deploy
script:
- sha256sum -c out/release.txt.sha256
- echo "synthetic_deploy=true"
rules:
- if: '$CI_DEPLOY_FREEZE'
when: manual
allow_failure: true
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
when: manual
allow_failure: false
- when: never
Prediction: during a freeze the routine release does not auto-run. It becomes an explicit exception path. Whether an actor is allowed to take that exception is a separate authorization question.
12. Simulate rollback with immutable identity
Record the checksum from the successful deployment. Create a deliberate failed synthetic deployment job that checks the same artifact and exits non-zero after printing only a synthetic reason. Preserve the failure. Recovery must deploy the previously known checksum/commit, not rerun a build against mutable dependencies.
simulate_release_failure:
stage: deploy
needs:
- job: build_release
artifacts: true
script:
- sha256sum -c out/release.txt.sha256
- echo "ERROR: synthetic release rejected" >&2
- exit 42
environment:
name: release-control/ch18
url: https://ch18.example.invalid
resource_group: ch18-release
when: manual
allow_failure: false
For recovery, run a new safe deployment job/pipeline tied to the known commit/artifact. If you inspect GitLab’s retry/rollback UI, treat it as a mechanism whose behavior must preserve the intended identity—not as proof by itself.
13. Challenge: choose the right clock
You need a job to run every night even if nobody pushes. Should you
use start_in: 24 hours or a pipeline schedule? Use a
pipeline schedule: the requirement is recurring pipeline creation
independent of the lifetime of any one pipeline.
start_in is for a delay inside an existing pipeline and
is capped at one week.
14. Cleanup all server-side and Git refs you created
Delete only the schedule whose captured ID is
SCHEDULE_ID:
# Verify before delete.
glab api "projects/$PROJECT_ID/pipeline_schedules/$SCHEDULE_ID" --jq '{id,description,ref,active}'
# Destructive but targeted: deletes the disposable schedule object only.
glab api --method DELETE "projects/$PROJECT_ID/pipeline_schedules/$SCHEDULE_ID"
# Verify it is gone; a 404 is expected after deletion.
# Then stop the synthetic environment in the UI if still available.
git switch main
git ls-remote --heads origin refs/heads/ch18/release-control-lab
# Delete only after the exact ref is confirmed:
git push origin --delete ch18/release-control-lab
git branch -D ch18/release-control-lab
If you created a live freeze period, verify its exact ID/cron/time zone before deleting it via UI/API. Never “clean up all freeze periods.”
15. Evidence bundle
- Pipeline ID + SHA + source.
-
Manual gate job ID/status/actor and explicit
allow_failurebehavior. - Delayed job timestamps plus whether it completed or was unscheduled.
- Schedule ID/owner/ref/cron/active state, with no credentials.
- Freeze-policy fixture or exact disposable freeze-period ID if used.
- Artifact checksum and deployment history before failure and after recovery.
- Cleanup proof for schedule/freeze/ref/environment.
Knowledge check
Why is the schedule created inactive in the mandatory path?
To teach the server-side schedule object and ownership/ref/cron state without creating recurring compute or unexpected side effects.
What proves the blocking manual gate applies to the intended code?
The pipeline/job SHA must equal the recorded disposable branch commit, and the deploy consumes the checksum-identified artifact from that pipeline.
What does Unschedule do to a delayed job?
It stops the active delay timer; the job no longer runs automatically, but can still be started manually if appropriate.
Why do you record the schedule owner?
Scheduled pipelines execute with the owner’s permissions, so ownership is part of the execution identity and can become stale/inactive.
Why is the synthetic failure not hidden with
allow_failure: true?
The failed release path is diagnostic evidence; masking it would destroy the signal the lab is designed to interpret.
Summary
You created the release-control objects without production risk: optional and blocking manual jobs, a delayed job, an inactive schedule, freeze-policy behavior, one controlled failure, and a known-artifact recovery. The key evidence is always actor/time/source/SHA/artifact/environment—not the color of a single button.
Official references
- GitLab Docs — Control how jobs run
- GitLab Docs — CI/CD YAML syntax reference
- GitLab Docs — Scheduled pipelines
- GitLab Docs — Pipeline schedules API
- GitLab Docs — Deployment safety
- GitLab Docs — Deployments
- GitLab Docs — Environments
- GitLab Docs — Freeze Periods API
- GitLab Docs — Predefined CI/CD variables
- GitLab Docs — Pipeline settings
- GitLab Docs — Pipelines
- GitLab Docs — Job troubleshooting
- GitLab Docs — Resource groups
- GitLab Docs — Protected environments
- GitLab Docs — Deployment approvals
- GitLab Docs — Job token
- GitLab Docs — Roles and permissions
- GitLab 19.3 — What’s new
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this
address.