aboutsummaryrefslogtreecommitdiffstats
path: root/history.html
blob: 0567305accafbaa28c4f2227808413c8883d38e2 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
<!doctype HTML public "-//W3O//DTD W3 HTML 3.2//EN">
<HTML>
<HEAD>
<link rev=made href=mailto:esr@snark.thyrsus.com>
<meta name="description" content="Fetchmail participation statistics">
<meta name="keywords" content="fetchmail, growth, analysis"> 
<TITLE>Trends in the fetchmail project's growth</TITLE>
</HEAD>
<BODY>
<table width="100%" cellpadding=0><tr>
<td width="30%">Back to <a href="/~esr">Eric's Home Page</a>
<td width="30%" align=center>Up to <a href="/~esr/sitemap.html">Site Map</a>
<td width="30%" align=right>$Date: 1999/12/20 04:35:07 $
</table>
<HR>
<H1 ALIGN=CENTER>Trends in the fetchmail project's growth</H1>

The scattergram below was made with Gnuplot 3.7 from data pulled
directly out of the project NEWS file using two custom shellscripts,
<a href="timeseries">timeseries</a> and <a
href="growthplot">growthplot</a>.  If you see a broken-image icon, upgrade
to a <a href="http://www.cdrom.com/pub/png/pngapbr.html">browser that
can view PNGs</a>.<p>

<center><img src="growth.png"></center><p>

The graph shows the population growth of the fetchmail project.  The
horizontal scale is days since baseline, which is when I started
collecting statistics in October 1996 at version 1.9.0.  Left vertical
scale is number of participants. There is one data point for each
release; therefore, the changes in density of marks indicate release
frequency.<p>

The peak in the earliest part of the graph (before the note "Bad
addresses dropped") seems to be an artifact; I was not regularly
dropping addresses that became invalid at the time.  Turnover on the
list seems to be about 5% per month (but that's just my estimate, I
don't have numbers on this).<p>

The <font color="blue">blue scatter of squares</font> is total
participants.  The <font color="lime">green scatter of crosses</font> is
the count of people on fetchmail-friends after I split the list.  The
<font color="purple">violet scatter of triangles</font> is the population
of fetchmail-announce after the split.<P>

The <font color="brown">brown scatter of diamonds</font> tracks project
size in lines of code (right vertical axis). The scale relationship
between this scatter and the other three is arbitrary.<p>

This graph is quite revealing.  Several trends stand out: <p>

<ul>
<li>
Over time, the project population displays rather consistent linear growth.<p>

<li>
The key event in the project's lifetime was release 4.3.0 in October
1997, when I declared the code to be out of development and in
maintainance mode, and split the fetchmail list.<p>

<li>
The run-up to 4.3.0 saw the most intensive spate of releases in the 
project's history (the gap in that run happened when I took a two-week
vacation).  It was followed by a significant slowdown.<p>

<li>
After 4.3.0, the developer population remained fairly stable around
an average of about 250 participants.<p>

<li>
Essentially all population growth after 4.3.0 happened on the announce list,
among people using fetchmail but not  active co-developers.<p>

<li>
The growth trend in code size looks sublinear, perhaps logarithmic.
</ul>

The linear growth trend in population is particularly interesting; a
priori we might expect geometric or logistic growth, given that the
project spreads by word of mouth.<P>

It has been suggested that the linear growth rate is the result of a 
situation in which both number of projects and the population of
eligible programmers are rising on trend curves of the same (probably
exponential) rate.<p>

There are some other pages doing similar things:<p>

<ul>
<li>
<a href="http://kitenet.net/programs/debhelper/stats/">Here</a>
are growth statistics on the debhelper packaging utility.<p>

<li>
<a href="http://durak.org:81/sean/pubs/kfc/">Here</a> is a page on the
vocabulary of the Linux kernel.<p>
</ul>

<HR>
<table width="100%" cellpadding=0><tr>
<td width="30%">Back to <a href="/~esr">Eric's Home Page</a>
<td width="30%" align=center>Up to <a href="/~esr/sitemap.html">Site Map</a>
<td width="30%" align=right>$Date: 1999/12/20 04:35:07 $
</table>

<P><ADDRESS>Eric S. Raymond <A HREF="mailto:esr@thyrsus.com">&lt;esr@thyrsus.com&gt;</A></ADDRESS>
</BODY>
</HTML>
336699; font-style: italic } /* Name.Label */ .highlight .nn { color: #bb0066; font-weight: bold } /* Name.Namespace */ .highlight .py { color: #336699; font-weight: bold } /* Name.Property */ .highlight .nt { color: #bb0066; font-weight: bold } /* Name.Tag */ .highlight .nv { color: #336699 } /* Name.Variable */ .highlight .ow { color: #008800 } /* Operator.Word */ .highlight .w { color: #bbbbbb } /* Text.Whitespace */ .highlight .mb { color: #0000DD; font-weight: bold } /* Literal.Number.Bin */ .highlight .mf { color: #0000DD; font-weight: bold } /* Literal.Number.Float */ .highlight .mh { color: #0000DD; font-weight: bold } /* Literal.Number.Hex */ .highlight .mi { color: #0000DD; font-weight: bold } /* Literal.Number.Integer */ .highlight .mo { color: #0000DD; font-weight: bold } /* Literal.Number.Oct */ .highlight .sa { color: #dd2200; background-color: #fff0f0 } /* Literal.String.Affix */ .highlight .sb { color: #dd2200; background-color: #fff0f0 } /* Literal.String.Backtick */ .highlight .sc { color: #dd2200; background-color: #fff0f0 } /* Literal.String.Char */ .highlight .dl { color: #dd2200; background-color: #fff0f0 } /* Literal.String.Delimiter */ .highlight .sd { color: #dd2200; background-color: #fff0f0 } /* Literal.String.Doc */ .highlight .s2 { color: #dd2200; background-color: #fff0f0 } /* Literal.String.Double */ .highlight .se { color: #0044dd; background-color: #fff0f0 } /* Literal.String.Escape */ .highlight .sh { color: #dd2200; background-color: #fff0f0 } /* Literal.String.Heredoc */ .highlight .si { color: #3333bb; background-color: #fff0f0 } /* Literal.String.Interpol */ .highlight .sx { color: #22bb22; background-color: #f0fff0 } /* Literal.String.Other */ .highlight .sr { color: #008800; background-color: #fff0ff } /* Literal.String.Regex */ .highlight .s1 { color: #dd2200; background-color: #fff0f0 } /* Literal.String.Single */ .highlight .ss { color: #aa6600; background-color: #fff0f0 } /* Literal.String.Symbol */ .highlight .bp { color: #003388 } /* Name.Builtin.Pseudo */ .highlight .fm { color: #0066bb; font-weight: bold } /* Name.Function.Magic */ .highlight .vc { color: #336699 } /* Name.Variable.Class */ .highlight .vg { color: #dd7700 } /* Name.Variable.Global */ .highlight .vi { color: #3333bb } /* Name.Variable.Instance */ .highlight .vm { color: #336699 } /* Name.Variable.Magic */ .highlight .il { color: #0000DD; font-weight: bold } /* Literal.Number.Integer.Long */
#!/usr/bin/env python
#
# shipper -- a tool for shipping software

import sys, os, readline, re, commands, time, glob, optparse, stat

#
# State variables
#
destinations = []	# List of remote directories to update
channels = ['ibiblio', 'redhat', 'freshmeat']
whoami = None		# Who am I? (Used for FTP logins)
date = None		# User has not yet set a date
package = None   	# Nor a package name
homepage = None		# Nor a home page
arch = None       	# The machine architecture
keywords = None  	# Keywords for LSMs
freshmeat_name = None	# Name of the project ob Freshmeat
changelog = None	# Project changelog
lastchange = None  	# Last entry in changelog
summary = None   	# One-line summary of the package
description = None	# Nor a description

indextemplate = """
<?xml version="1.0" encoding="ISO-8859-1"?>
<!DOCTYPE html PUBLIC '-//W3C//DTD XHTML 1.0 Transitional//EN'
    'http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd'>
<html>
<head>
<link rel='stylesheet' href='/~esr/sitestyle.css' type='text/css' />
<meta name='description' content='Resource page for %(package)s' />
<meta name='generator' content='shipper' />
<meta name='MSSmartTagsPreventParsing' content='TRUE' />
<title>Resource page for %(package)s %(version)s</title>
</head>
<body>

<h1>Resource page for %(package)s %(version)s</td></h1>

<p>%(description)s</p>

<br />
%(resourcetable)s
<br />

<p>Last modified %(date)s.</p>

</div>
</body>
</html>
"""
mailtemplate = """Subject: Announcing release %(version)s of %(package)s

Release %(version)s of %(package)s is now available at:

	%(homepage)s

Here are the most recent changes:

%(lastchange)s
--
                             shipper, acting for %(whoami)s
"""

# It's unpleasant that we have to include these here, but
# the freshmeat release focus has to be validated even if the
# user is offline and the XML-RPC service not accessible.
freshmeat_focus_types = (
"N/A",
"Initial freshmeat announcement",
"Documentation",
"Code cleanup",
"Minor feature enhancements",
"Major feature enhancements",
"Minor bugfixes",
"Major bugfixes",
"Minor security fixes",
"Major security fixes",
)

def croak(msg):
    sys.stderr.write("shipper: " + msg + "\n")
    sys.exit(1)

#
# Shipping methods
#

def do_or_die(cmd):
    "Wither execute a command or fail noisily"
    if options.verbose:
        print "***", cmd
    if os.system(cmd):
        croak("command '%s' failed!" % cmd)

def upload_or_die(cmd):
    if options.noupload:
        print cmd
    else:
        do_or_die(cmd)

def upload(destination, files):
    # Upload a file via ftp or sftp, handles 
    print "# Uploading to %s" % destination
    files = filter(os.path.exists, files)
    if destination.startswith("ftp://"):
        destination = destination[6:].split("/")
        host = destination.pop(0)
        directory = "/".join(destination)
        commands = ["lftp", "open -u anonymous," + whoami + " " + host + "\n"]
        if directory:
            commands.append("cd " + directory + "\n")
        commands.append("mput " + " ".join(files) + "\n")
        commands.append("close\n")
        if options.noupload:
            print "".join(commands)
        else:
            pfp = os.popen(commands.pop(0), "w")
            pfp.writelines(commands)
            pfp.close()
    elif destination.find("::") > -1:
        upload_or_die("rsync " + " ".join(files) + " " + destination)
    elif destination.find(":") > -1:
        (host, directory) = destination.split(":")
        for file in files:
            # This is a really ugly way to deal with the problem
            # of write-protected files in the remote directory.
            # Unfortunately, sftp(1) is rather brain-dead -- no
            # way to ignore failure on a remove, and refuses to
            # do renames with an obscure error message.
            remote = os.path.join(directory, package, file)
            upload_or_die("scp " + file + " " + host + ":" + remote+".new;")
            upload_or_die("ssh %s 'mv -f %s.new %s'" % (host, remote, remote))
    else:
        sys.stderr.write("Don't know what to do with destination %s!")

def freshmeat_ship(manifest):
    "Ship a specified update to freshmeat."
    if options.verbose:
        print "Announcing to freshmeat..."
    upload_or_die("freshmeat-submit <" + manifest[0])

#
# Metadata extraction
#

def grep(pattern, file):
    "Mine for a specified pattern in a file."
    fp = open(file)
    try:
        while True:
            line = fp.readline()
            if not line:
                return None
            m = re.search(pattern, line)
            if m:
                return m.group(1)
    finally:
        fp.close()
    return None

class Specfile:
    def __init__(self, filename):
        self.filename = filename
        self.type = None
        if filename.endswith(".spec"):
            self.type = "RPM"
            self.package = self.extract("Name")
            self.version = self.extract("Version")
            self.homepage = self.extract("URL")
            self.summary = self.extract("Summary")
            self.arch = self.extract("BuildArch") or commands.getoutput("rpm --showrc | sed -n '/^build arch/s/.* //p'")
            self.description = self.rpm_get_multiline("description")
            self.changelog = self.rpm_get_multiline("changelog")
        elif filename == "control":
            self.type = "deb"
            self.name = self.extract("Package")
            self.version = self.extract("Version").split("-")[0]
            self.homepage = self.extract("XBS-Home-Page")
            self.summary = self.extract("Description")
            self.arch = self.extract("Architecture")
            if not self.arch:
                croak("this control file lacks an Architecture field")
            # FIXME: parse Debian description entries and changelog file
            self.description = self.changelog = None
    def extract(self, fld):
        "Extract a one-line field, possibly embedded as a magic comment."
        if self.type == "RPM":
            return grep("^#?"+fld+":\s*(.*)", self.filename)
        elif self.type == "deb":
            return grep("^(?:XBS-)?"+fld+": (.*)", self.filename)
    def rpm_get_multiline(self, fieldname):
        "Grab everything from leader line to just before the next leader line."
        global desc
        fp = open(self.filename)
        desc = ""
        gather = False
        while True:
            line = fp.readline()
            if not line:
                break
            # Pick up fieldnames *without* translation options.
            if line.strip() == "%" + fieldname:
                gather = True
                continue
            elif line[0] == "%":
                gather = False
            if gather:
                desc += line
        fp.close()
        if desc:
            return desc.strip() + "\n"
        else:
            return None
#
# Main sequence
#

try:
    #
    # Process options
    #

    parser = optparse.OptionParser(usage="%prog: [-h] [-n] [-f] [-v]")
    parser.add_option("-v", "--verbose",
                      action="store_true", dest="verbose", default=False,
                      help="print progress messages to stdout")
    parser.add_option("-n", "--noupload",
                      action="store_true", dest="noupload", default=False,
                      help="don't do uploads, just build deliverables")
    parser.add_option("-N", "--nobuild",
                      action="store_true", dest="nobuild", default=False,
                      help="dump configuration only, no builds or uploads")
    parser.add_option("-f", "--force",
                      action="store_true", dest="force", default=False,
                      help="force rebuilding of all local deliverables")
    (options, args) = parser.parse_args()

    #
    # Extract metadata and compute control information
    #

    def disable(s): channels.remove(s)

    # Security check, don't let an attacker elevate privileges 
    def securecheck(file):
        if stat.S_IMODE(os.stat(file).st_mode) & 00002:
            croak("%s must not be world-writeable!" % file)

    # Read in variable overrides
    securecheck(".")
    home_profile = os.path.join(os.getenv('HOME'), ".shipper")
    if os.path.exists(home_profile):
        securecheck(home_profile)
        execfile(home_profile)
    here_profile = ".shipper"
    if os.path.exists(here_profile):
        securecheck(here_profile)
        execfile(here_profile)

    # Set various sensible defaults
    if not whoami:
        whoami = os.getenv('USERNAME') + "@" + os.getenv('HOSTNAME')

    # Where to get the metadata
    specfiles = glob.glob("*.spec")
    if len(specfiles) == 1:
        metadata = Specfile(specfiles[0])
    elif os.path.exists("control"):
        metadata = Specfile("control")
    else:
        croak("must be exactly one RPM or dpkg specfile in the directory!")

    # Get the package name
    if not package:
        package = metadata.package
    if not package:
        croak("can't get package name!")

    # Extract the package vers from the specfile or Makefile
    specvers = metadata.version
    makevers = None
    if os.path.exists("Makefile"):
        makevers = grep("^VERS[A-Z]* *= *(.*)", "Makefile")
        # Maybe it's a shell command intended to extract version from specfile
        if makevers and makevers[0] == '$':
            makevers = commands.getoutput(makevers[7:-1])
    if specvers != makevers:
        croak("specfile version %s != Makefile version %s"%(specvers,makevers))
    elif specvers == None:
        croak("can't get package version")
    elif specvers[0] not in "0123456789":
        croak("package version %s appears garbled" % specvers)
    else:
        version = specvers

    # Specfiles may set their own destinations
    local_destinations = metadata.extract("Destinations")
    if local_destinations:
        local_destinations = map(lambda x: x.strip(), local_destinations.split(","))
        destinations += local_destinations
    if not destinations:
        print "warning: destinations empty, shipping to public channels only."

    print"# Uploading version %s of %s" % (version, package)

    # Extract remaining variables for templating
    if not homepage:
        homepage = metadata.homepage
    if not date:
        date = time.asctime()
    if not summary:
        summary = metadata.summary
    if not description:
        description = metadata.description
    if not arch:
        arch = metadata.arch
    if not keywords:
        keywords = metadata.extract("Keywords")
    if not freshmeat_name:
        freshmeat_name = metadata.extract("Freshmeat-Name")

    # Finally, derive the change log and lastchange entry;
    # we'll need the latter for freshmeat.net
    freshmeat_lastchange = lastchange = changelog = None
    # ChangeLog, if present, takes precedence;
    # we assume if both are present that the specfile log is about packaging.
    if os.path.exists("ChangeLog"):
        ifp = open("ChangeLog", "r")
        changelog = ifp.read()
        ifp.close()
        lastchange = ""
        for line in changelog.split("\n"):
            while line.strip() or not "*" in lastchange:
                lastchange += line + "\n"
            else:
                break
        # freshmeat.net doesn't like bulleted items in a changes field.
        freshmeat_lastchange = "See the ChangeLog file for recent changes."
    elif metadata.changelog:
        changelog = metadata.changelog
        lastchange = ""
        for line in changelog.split("\n"):
            if not lastchange and (not line.strip() or line[0] == '*'):
                continue
            elif line.strip():
                lastchange += line + "\n"
            else:
                break
        # This usually produces a lastchange entry that freshmeat will take.
        freshmeat_lastchange = lastchange

    #
    # Now compute the names of deliverables
    #

    # These are all potential deliverable files that include the version number
    tarball   = package + "-" + version + ".tar.gz"
    srcrpm    = package + "-" + version + "-1.src.rpm"
    binrpm    = package + "-" + version + "-1." + arch + ".rpm"
    zip       = package + "-" + version + ".zip"
    lsm       = package + "-" + version + ".lsm"

    # Map web deliverables to explanations for the resource table
    # Stuff not included here: ANNOUNCE.EMAIL, ANNOUNCE.FRESHMEAT, lsm.
    stock_deliverables = [
        ("README",	"roadmap file"),
        (tarball,	"source tarball"),
        (zip,   	"ZIP archive"),
        (binrpm,	"installable RPM"),	# Generated
        (srcrpm,	"source RPM"),		# Generated
        ("ChangeLog",	"change log"),
        ("CHANGES",	"change log"),		# Generated
        ("NEWS",	"Project news"),
        ("HISTORY",	"Project history"),
        ("BUGS",	"Known bugs"),
        ("TODO",	"To-do file"),
        ]

    #
    # Might be time to dump
    #
    if options.nobuild:
        for variable in ('destinations', 'channels', 'whoami', 'date', 
                         'package', 'homepage', 'arch', 'keywords', \
                         'freshmeat_name', 'summary'):
            print "%s = %s" % (variable, `eval(variable)`)
        for variable in ('description', 'changelog', 'lastchange', 'mailtemplate', 'indextemplate'):
            if not eval(variable):
                print "No %s" % variable
            else:
                print "%s = <<EOF\n%sEOF" % (variable, eval(variable))
        sys.exit(0)
    #
    # Build deliverables
    #

    suppress = " >/dev/null 2>&1"
    if options.verbose:
        suppress = ""

    # Sanity checks
    if not os.path.exists(tarball):
        croak("no tarball %s!" % tarball)
    if metadata.type == "RPM" and not metadata.extract("BuildRoot"):
        croak("specfile %s doesn't have a BuildRoot!" % metadata.filename)

    def newer(f1, f2):
        return os.path.exists(f1) and (os.stat(f1).st_mtime > os.stat(f2).st_mtime)

    # Compute the deliverables, we need this even if not rebuilding the index
    web_deliverables = []
    # Anything in the list of standard deliverables is eligible.
    for (file, explanation) in stock_deliverables:
        if os.path.exists(file):
            web_deliverables.append((file, explanation))
    # So is anything with an HTML extendion
    for file in glob.glob('*.html'):
        if file == 'index.html':
            continue
        stem = file[:-4]
        for ext in ("man", "1", "2", "3", "4", "5", "6", "7", "8", "9", "xml"):
            if os.path.exists(stem + ext):
                explanation = "HTML rendering of " + stem + ext
                break
        else:
            explanation = "HTML page."
        web_deliverables.append((file, explanation))
    # Compute final deliverables
    deliverables = map(lambda x: x[0], web_deliverables)+["index.html"]

    try:
        delete_at_end = []

        # RPMs first.
        if options.force or \
               (not os.path.exists(binrpm) or not os.path.exists(srcrpm)):
            print "# Building RPMs..."
            if newer(srcrpm, tarball) and newer(binrpm, tarball):
                print "RPMs are up to date"
            else:
                do_or_die("buildrpms %s %s" % (tarball, suppress))
                delete_at_end.append(srcrpm)
                delete_at_end.append(binrpm)

        # Next, the LSM if needed
        if 'ibiblio' in channels and \
               (options.force or not os.path.exists(lsm)):
            print "# Building LSM..."
            if keywords:
                do_or_die("rpm2lsm -k '"+keywords+"' "+binrpm+" >"+lsm)
            else:
                print "# Warning: LSM being built with no keywords." 
                do_or_die("rpm2lsm " + binrpm + ">" + lsm)
            delete_at_end.append(lsm)

        # Next the index page if it doesn't exist.
        if homepage and (options.force or not os.path.exists("index.html")):
            print "# Building index page..."
            # Now build the resource table
            resourcetable = '<table border="1" align="center" summary="Downloadable resources">\n'
            for (file, explanation) in web_deliverables:
                resourcetable += "<tr><td><a href='%s'>%s</a></td><td>%s</td></tr>\n" % (file,file,explanation)
            resourcetable += "</table>"
            # OK, now build the index page itself
            ofp = open("index.html", "w")
            ofp.write(indextemplate % globals())
            ofp.close()
            delete_at_end.append("index.html")

        # Next the CHANGES file.  Build this only if (a) there is no ChangeLog,
        # and (b) there is a specfile %changelog.
        if not os.path.exists("ChangeLog") and \
               (options.force or not os.path.exists("CHANGES")) and changelog:
            print "# Building CHANGES..."
            ofp = open("CHANGES", "w")
            ofp.write("                     Changelog for " + package + "\n\n")
            ofp.write(changelog)
            ofp.close()
            delete_at_end.append("CHANGES")

        # The freshmeat announcement
        if 'freshmeat' in channels \
               and options.force or not os.path.exists("ANNOUNCE.FRESHMEAT"):
            print "# Building ANNOUNCE.FRESHMEAT..."
            if not homepage:
                print "# Can't announce to freshmeat without a primary website!"
            elif not lastchange:
                print "# Can't announce to freshmeat without a changes field!"
            else:
                while True:
                    focus = raw_input("# freshmeat.net release focus (? for list): ")
                    if focus == '?':
                        i = 0
                        for f in freshmeat_focus_types:
                            print "%d: %s" % (i, f)
                            i += 1
                    elif focus in "0123456789":
                        print "# OK:", freshmeat_focus_types[int(focus)]
                        break
                    elif focus.lower() in map(lambda x: x.lower(), freshmeat_focus_types):
                        break
                    else:
                        croak("not a valid freshmeat.net release focus!")
                ofp = open("ANNOUNCE.FRESHMEAT", "w")
                ofp.write("Project: %s\n"%(freshmeat_name or package))
                ofp.write("Version: %s\n"% version)
                ofp.write("Release-Focus: %s\n" % focus)
                ofp.write("Home-Page-URL: %s\n" % homepage)
                if os.path.exists(tarball):
                    ofp.write("Gzipped-Tar-URL: %s\n" % os.path.join(homepage,tarball))
                if os.path.exists(zip):
                    ofp.write("Zipped-Tar-URL: %s\n" % os.path.join(homepage, zip))
                if os.path.exists("CHANGES"):
                    ofp.write("Changelog-URL: %s\n" % os.path.join(homepage, "CHANGES"))
                if os.path.exists(binrpm):
                    ofp.write("RPM-URL: %s\n" % os.path.join(homepage, binrpm))
                # freshmeat.net doesn't like bulleted entries.
                freshmeatlog = lastchange[2:].replace("\n  ", "\n")
                ofp.write("\n" + freshmeatlog)
                ofp.close()
                delete_at_end.append("ANNOUNCE.FRESHMEAT")

        # Finally, email notification
        if filter(lambda x: x.startswith("mailto:"), destinations) \
               and (options.force or not os.path.exists("ANNOUNCE.EMAIL")):
            print "# Building ANNOUNCE.EMAIL..."
            ofp = open("ANNOUNCE.EMAIL", "w")
            ofp.write(mailtemplate % globals())
            ofp.close()
            delete_at_end.append("ANNOUNCE.FRESHMEAT")

        #
        # Now actually ship
        #

        # Shipping methods, locations, and deliverables for public channels.
        hardwired = {
            'freshmeat'	: (lambda: freshmeat_ship(("ANNOUNCE.FRESHMEAT",))),
            'ibiblio'	: (lambda: upload("ftp://ibiblio.org/incoming/linux",
                                          (tarball, binrpm, srcrpm, lsm))),
            'redhat'	: (lambda: upload("ftp://incoming.redhat.com/libc6", 
                                          (tarball, binrpm, srcrpm))),
        }


        # First ship to private channels.  Order is important here, we
        # need to hit the user's primary website first so everything
        # will be in place when announcements are generated.
        for destination in destinations:
            if destination.startswith("ftp:"):
                upload(destination, (tarball, binrpm, srcrpm,))
            elif destination.startswith("mailto:"):
                print "# Mailing to %s" % destination
                command = "sendmail -i -oem -f %s %s <ANNOUNCE.EMAIL" % (whoami, destination[7:])
                if options.noupload:
                    print command
                else:
                    do_or_die(command)
            else:
                upload(destination, deliverables)

        # Now ship to public channels
        for channel in channels:
            print "# Shipping to public channel", channel
            apply(hardwired[channel])
    finally:
        cleanup = "rm -f " + " ".join(delete_at_end)
        if options.noupload:
            print cleanup
        else:
            for file in delete_at_end:
                os.system(cleanup)
    print "# Done"
except KeyboardInterrupt:
    print "# Bye!"



# The following sets edit modes for GNU EMACS
# Local Variables:
# mode:python
# End: